Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

43 KiB
Raw Permalink Blame History

SCRATCHPAD — Hudu Integration

Rolling working memory. Append freely.

Context

  • Branch: integrations_hudu. Greenfield — zero "hudu" references in repo at start.
  • Goal: pull-only (Hudu → AlgaPSA) integration, EE-only, gated behind hudu-integration feature flag.
  • Hudu = MSP IT-documentation platform. REST JSON API. Auth = x-api-key header + per-instance base URL (NOT OAuth).
  • Local reference skills (not committed; .claude/ is gitignored): hudu-api-patterns, hudu-companies, hudu-assets, hudu-articles, hudu-passwords, hudu-websites.

Hudu API facts (from reference skills)

  • Base URL: https://<instance>/api/v1/<resource>; Hudu Cloud or self-hosted.
  • Pagination: ?page=N, fixed 25 items/page; page < 25 results ⇒ last page.
  • Rate limit: 300 req/min; 429 ⇒ backoff (Retry-After + jitter / exponential).
  • UI→API naming traps: Passwords→asset_passwords, Processes→procedures.
  • id_in_integration / integration_slug on companies = PSA cross-link hook.
  • Asset passwords API returns plaintext password values. API-key password access is a per-key permission (403 if denied).

AlgaPSA architecture findings (from repo exploration)

Integration framework

  • EE integration libs: ee/server/src/lib/integrations/<provider>/ (ninjaone, entra, tanium). Axios clients.
  • Canonical client example: ee/server/src/lib/integrations/ninjaone/ninjaOneClient.ts (credential layering: tenant secret → app secret → env).
  • Settings catalog (renders Settings→Integrations): packages/integrations/src/components/settings/integrations/IntegrationsSettingsPage.tsxcategories array of IntegrationItem { id, name, description, component, isEE? }. Add a new "IT Documentation" category (don't shoehorn into RMM — Hudu is docs, not RMM).
  • RMM provider registry (pattern ref only): packages/integrations/src/lib/rmm/providerRegistry.ts.

Feature flags

  • Server: featureFlags.isEnabled('hudu-integration', { userId, tenantId }). Guard pattern: ee/server/src/app/api/integrations/entra/_guards.ts (requireEntraUiFlagEnabled).
  • Client: useFeatureFlag('hudu-integration', { defaultValue: false }).

CE/EE stub

  • CE route lazy-imports EE via @enterprise/... alias, else returns eeUnavailable() 501.
  • Example: server/src/app/api/integrations/entra/route.ts + _ceStub.ts. Simple re-export variant: server/src/app/api/integrations/ninjaone/callback/route.ts.
  • tsconfig aliases (tsconfig.base.json): @enterprisepackages/ee/src, @eepackages/ee/src. EDITION env: isEnterpriseEdition = EDITION==='ee' || NEXT_PUBLIC_EDITION==='enterprise'.

Secrets

  • ISecretProvider: getAppSecret, getTenantSecret(tenantId,name), setTenantSecret(tenantId,name,value|null), deleteTenantSecret.
  • Get instance: getSecretProviderInstance(). Store Hudu creds as hudu_api_key, hudu_base_url (per tenant). NinjaOne stores JSON blob under one key — viable too.
  • Metadata layer: tenant_secrets table + createTenantSecretProvider(knex, tenantId). Perms: secrets.view/manage/use.

Server actions & DB

  • Server actions: 'use server' + withAuth(async (user, { tenant }, args) => ...) + createTenantKnex().
  • Migrations: CE in server/migrations/*.cjs, EE in ee/server/migrations/*.cjs (Knex CJS). Tenant tables: PK (tenant uuid, <id> uuid), FKs (tenant, ref), updated_at trigger on_update_timestamp(), Citus-distributed by tenant. App-layer isolation (no RLS).
  • Connection-state shape precedent: server/migrations/20251124000001_create_rmm_integration_tables.cjsrmm_integrations (tenant, integration_id, provider, instance_url, is_active, connected_at, last_sync_at, settings jsonb). BUT location: rmm_integrations is CE only because the RMM layer is partly CE; Hudu is wholly EE, so hudu_integrations goes in ee/server/migrations/ (exists, 49 migrations, Citus-distributes EE-only tables). EE precedent to mirror: ee/server/migrations/20260220143000_create_entra_phase1_schema.cjs (Entra = EE-only, flag-gated, connection-state — the true peer). CE route 501s before any DB access, so CE never needs the table.
  • Generic mapping table: external_entity_mappings (tenant, ..., asset_id NOT NULL, import_source_id, external_id, external_hash, metadata)asset-scoped only, so NOT reusable for company↔client. Need dedicated Hudu mapping table(s).

Entity mapping targets

Hudu Alga table Interface
Company clients IClient (client_id, client_name, properties jsonb)
Contact contact_names IContact
Asset assets (+ asset_facts for synced metadata) Asset, AssetFact (provider, integration_id, namespace, fact_key)
Article documents (polymorphic via document_associations (tenant, document_id, entity_id, entity_type)) IDocument
Asset Password no native table
Website none (lives in clients.properties) — DEFERRED

Asset Passwords placeholder (KEY)

  • packages/assets/src/components/tabs/DocumentsPasswordsTab.tsx: passwords half is a STUB — card titled "Passwords & Secrets", text "Secure password management coming soon." No DB table, no persistence.
  • Tab registered in packages/assets/src/components/AssetDetailTabs.tsx (id documents-passwords, icon Lock), rendered by AssetDetailView.tsx.
  • Documents already attach to assets (real) via document_associations (entity_type='asset'); see packages/assets/src/components/AssetDocuments.tsx.
  • NOTE scope mismatch: Hudu asset_passwords are company-scoped, but the placeholder tab is on the asset. Resolve where company-scoped Hudu creds surface.

Hard constraints / decisions

  • SECURITY: never store Hudu password plaintext in an Alga DB column. Live-fetch + mask + reveal-on-demand; if persistence ever needed, encrypt via secretProvider only. Respect API-key password permission (handle 403). Audit reveals.
  • Data model kept direction-agnostic (external-id mapping tables) so push (Alga→Hudu) is additive later.
  • Websites + push = out of scope for Phase 1.
  • EE/CE deletion boundary (NFR7): hudu_integrations is EE-only → all its DELETEs (disconnect + client/tenant cascade) live in EE code + EE migrations; CE must never name the table. Mapping cleanup is fine in CE (shared tenant_external_entity_mappings). User flagged: don't let a CE path try to delete from an EE-only table.
  • Vault confirmed as tenant-secret backend: packages/core/src/lib/secrets/VaultSecretProvider.ts + CompositeSecretProvider (read-chain/write-target). So setTenantSecret can persist to Vault (encrypted store, NOT a DB column).

Decisions (resolved 2026-06-08, evidence-based)

Research thread A (Hudu ecosystem) + thread B (repo mapping patterns) drove these:

  1. Sync model → persist mappings only; fetch a mapped company's lists on demand (cached + manual Refresh); deep-link for content. No scheduled sync engine in Phase 1. Rationale: Hudu has NO consumer webhooks (only Slack/Teams alerts), 300 req/min, 25/page, no bulk endpoints; the whole ecosystem polls low-frequency (Hudu PSA syncs 3h, CIPP 24h) — per-mapped-company on-demand is within limits; bulk-on-every-view is not.
  2. Assets/Articles/Passwords → reference + deep-link, do NOT import as native Alga records. Hudu is the system of record; PSAs deep-link back (ConnectWise "Quick Links"); pulling Hudu assets into a PSA is an unfilled HaloPSA request. Passwords especially: never duplicate secrets.
  3. Surface point → CLIENT page only (revised 2026-06-09): Client "Hudu" tab (assets + articles) + a SEPARATE Client "Passwords" tab (Hudu asset_passwords), shown only when Hudu enabled/connected + client mapped. NOT on the asset tab — Hudu passwords are company-scoped, can't map to an asset; the asset "Documents & Passwords" stub is left untouched (future native-password effort).
  4. Company↔Client matching → reuse generic tenant_external_entity_mappings (Xero/QBO modern shared table; integration_type='hudu', alga_entity_type='client') with NinjaOne OrganizationMappingManager single-table UX. Auto-suggest by id_in_integration (exact) → exact name → fuzzy name, admin confirms/overrides. NO new mapping migration.

Key Hudu API facts confirmed by research

  • PSA is source of truth for companies; Hudu imports them and stamps id_in_integration + integration_slug. Query filter ?id_in_integration= + deep-link /api/v1/companies/jump?integration_id=&integration_slug=&integration_type=company exist (shipped v2.1.1+).
  • No external webhooks/change-feed (only admin Slack/Teams alerts on create/update/delete). 300 req/min, 25/page, ~90 polling GET endpoints, no bulk read.
  • Reveal passwords via deep-link to Hudu (Phase 1) → zero secret transit through Alga, satisfies no-plaintext constraint.
  • Caveat: support.hudu.com returns 403 to fetchers; claims corroborated via hudu.com, Canny, community, and lwhitelock/HuduAPI.

Repo mapping pattern confirmed

  • Shared generic table: server/migrations/20250502173321_create_tenant_external_entity_mappings.cjs + CRUD packages/integrations/src/actions/externalMappingActions.ts (publishes EXTERNAL_MAPPING_CHANGED, 30s cache, 23505→"already exists"). REUSE — no new migration.
  • NinjaOne org-mapping UI: ee/server/src/components/settings/integrations/ninjaone/OrganizationMappingManager.tsx (ClientPicker per row, status badges, Refresh) — copy this UX.
  • Entra matchers (ee/server/src/lib/integrations/entra/mapping/matchers/) return 01 confidence — pattern for the auto-suggest.

Password model — RESOLVED 2026-06-09

  • On-demand reveal, NO storage. List shows metadata only; reveal = single live GET of one asset_password, value transits to the browser (masked, reveal-on-click), audited, never persisted to DB/Vault/cache or logged. Satisfies the no-plaintext constraint by construction.
  • Vault IS the tenant-secret backend (VaultSecretProvider), so a Vault-cached reveal was technically allowed — rejected to avoid secret duplication/staleness/blast-radius and bypassing Hudu's own access revocation.
  • Native AlgaPSA password store + importing Hudu into it = OUT OF SCOPE (separate future plan, "if demanded"). User confirmed 2026-06-09.
  • Plan threads: F067 (reveal action), F068 (audit), F073/F082 (inline Reveal UI), T067T069/T082/T110 (incl. "never to Vault"); NFR1 + Non-goals + OQ1 updated.

OQ2/3/4 — RESOLVED 2026-06-09

  • OQ2: passwords → dedicated Client "Passwords" tab (company-scoped); NOT asset tab; asset stub untouched. Plan group renamed asset-passwords-tabclient-passwords-tab (F080F083, T080T083); removed Hudu-tab passwords section (old F073/T072).
  • OQ3: reuse existing system_settings resource (read=view, update=manage). Evidence: Entra/Teams/Tactical-RMM/SSO all use system_settings; only billing/accounting (Xero/QBO + externalMappingActions) use billing_settings. So Hudu mapping actions write tenant_external_entity_mappings directly gated on system_settings — do NOT call the billing_settings-gated externalMappingActions wrappers. Dropped the new-hudu-resource + seeding (old F091/T092 removed).
  • OQ4: one instance per tenant (unique(tenant)). Multiple is rare (M&A/sandbox); deferred.

Still-open (none — all OQs resolved)

  • OQ2 asset↔password relation is best-effort/company-scoped (Hudu passwords aren't strongly asset-linked).
  • OQ3 new hudu RBAC resource vs reuse (default: new hudu resource, seed rows).
  • OQ4 one Hudu instance per tenant in Phase 1 (confirm).

Reference plan (house style)

  • ee/docs/plans/2026-04-06-tanium-rmm-integration-plan/ — closest analog (EE, pull-oriented RMM). Note: its features.json predates commitGroup; THIS plan WILL include commitGroup per software-planner spec.

Implementation log

scaffold (F001F005) — DONE + verified 2026-06-09 (uncommitted)

CE↔EE wiring pattern (reuse for ALL later route/lib groups):

  • @enterprise alias is edition-swapped in server/next.config.mjs:319-320: EE → ee/server/src, CE → packages/ee/src. (server/tsconfig.json statically points @enterprise at packages/ee/src for typecheck.)
  • So every EE route needs TWO files: the REAL impl at ee/server/src/app/api/integrations/hudu/<route> and a 501 stub copy at packages/ee/src/app/api/integrations/hudu/<route>. The CE entry at server/src/app/api/integrations/hudu/<route> lazy-imports @enterprise/app/api/integrations/hudu/<route> guarded by isEnterpriseEdition + assertSessionProductAccess, else eeUnavailable() 501.
  • Files created: EE lib ee/server/src/lib/integrations/hudu/{contracts.ts,index.ts}; EE route ee/server/src/app/api/integrations/hudu/{_guards.ts,_responses.ts,route.ts}; EE stub packages/ee/src/app/api/integrations/hudu/{_stub.ts,route.ts}; CE entry server/src/app/api/integrations/hudu/{_ceStub.ts,route.ts}; client gate packages/integrations/src/components/settings/integrations/useHuduIntegrationEnabled.ts.
  • Guard requireHuduUiFlagEnabled('read'|'update') mirrors Entra requireEntraUiFlagEnabled: flag hudu-integration via featureFlags.isEnabled, RBAC system_settings, EE tier+add-on, 404 when flag off. Reuse this guard in every EE Hudu route/action.
  • contracts.ts exports HUDU_INTEGRATION_TYPE + value-stripped HuduAssetPasswordSummary (no password field) for list payloads.
  • Verified: focused tsc on all 10 files = 0 errors; tsc -p packages/integrations/tsconfig.json clean for our files; remaining server/ee baseline errors are pre-existing and reference no hudu file.
  • Tests T001T004 written + green (13 cases). Group committed eca8008 (also includes the plan folder).

hudu-client (F010F017, T010T018) — DONE + verified 2026-06-09, committed

  • ee/server/src/lib/integrations/hudu/huduClient.ts (axios, x-api-key, pagination stop-at-<25, 429 Retry-After+jitter backoff capped at 4 attempts, 5xx exp backoff, validateConnection w/ password-access probe) + secrets.ts (HUDU_SECRET_KEYS = hudu_api_key/hudu_base_url; resolve tenant secret → env via getSecretProviderInstance()/getTenantSecret). Barrel updated.
  • Result shape: HuduResult<T> = {ok:true,data} | {ok:false,error:HuduError}; HuduError.kind ∈ invalid_key|no_password_access|not_found|validation|rate_limited|server_error|network_error|unknown. Read methods THROW HuduRequestError (carrying the typed HuduError); consuming actions (connection/reference-fetch groups) map throw→envelope. validateConnection() returns the struct directly.
  • Backoff sleep is injectable so tests run fast (no real timers). Redaction: errors built from status only, never bodies/headers/key.
  • 22 unit tests (axios vi.mocked, no network). Reuse createHuduClient() in later groups; do NOT add OAuth (x-api-key only).

connection (F020F028, T020T028) — DONE + verified 2026-06-09 (uncommitted)

  • Migration ee/server/migrations/20260609120000_create_hudu_integrations.cjs: tenant uuid FIRST col, integration_id default gen_random_uuid(), PK (tenant,integration_id), unique(tenant) (one connection/tenant), FK tenants CASCADE, settings jsonb '{}', update_hudu_integrations_updated_at trigger (on_update_timestamp), Entra-style citus guard (pg_is_in_recovery + pg_extension + pg_dist_partition, colocate_with => 'tenants' — Entra ensureDistributedTable precedent; teams uses microsoft_profiles only because of its FK), console.warn+skip when citus absent, GRANT to DB_USER_SERVER, transaction:false. down() drops the table.
  • Repository ee/server/src/lib/integrations/hudu/huduIntegrationRepository.ts: getHuduIntegration/upsertHuduIntegration/setHuduIntegrationActive/touchHuduIntegrationLastSynced, all (knex, tenant, ...) (contactLinkRepository style — callers/tests inject the handle). Upsert = insert onConflict(['tenant']).merge, partial-field merge keeps untouched cols. Exported from barrel.
  • Actions ee/server/src/lib/actions/integrations/huduActions.ts ('use server'): connectHudu/testHuduConnection/getHuduConnectionStatus/disconnectHudu, each wrapped in withHuduSettingsAccess(perm) = withAuth + user_type!=='client' + system_settings RBAC + assertTierAccess(INTEGRATIONS) + assertAddOnAccess(ENTERPRISE) + hudu-integration flag (action-level mirror of requireHuduUiFlagEnabled). Capability stored in settings.password_access. getStatus/route NEVER read or return the key. disconnect deletes both tenant secrets + setActive(false); mappings untouched. createTenantKnex imported from 'server/src/lib/db' (NOT '@/lib/db' — vitest alias for that subpath doesn't exist in EE).
  • EE route GET now returns real status via repository (status/baseUrl/connectedAt/lastSyncedAt/passwordAccess).
  • contracts.ts adds HUDU_MAPPING_TABLE = 'tenant_external_entity_mappings' — mapping group MUST use it (NFR7 boundary anchor; boundary test asserts it).
  • Tests (49 hudu tests green total): T020T022 REAL DB (src/__tests__/integration/hudu-integrations.migration.integration.test.ts — direct knex to local server DB :5432, postgres + secrets/postgres_password, single-migration up()/down() re-apply pattern; tests self-contained because vitest shuffles); T023T026 unit-mocked (huduConnectionActions.test.ts — mock '@alga-psa/auth' withAuth, repo + HuduClient via '@ee/...' specifiers which dedupe with relative imports); T027/T028 static fs sweep (huduDeletionBoundary.test.ts). T028 client-delete-removes-mapping-rows half deferred to the mapping group (noted in test).
  • Gotchas: array_agg(a.attname) (name[]) comes back as unparsed {tenant} string — cast ::text; ee tsconfig is non-strict so if (!result.success) does NOT narrow unions in tests — use toMatchObject.

settings-ui (F030F034, T030T034) — DONE + verified 2026-06-09 (uncommitted)

  • Bridging decision: NinjaOne/Tanium precedent (NOT the heavier Entra entry-swap). Real component ee/server/src/components/settings/integrations/HuduIntegrationSettings.tsx imports huduActions directly (relative path, like TaniumIntegrationSettings); CE placeholder stub at packages/ee/src/components/settings/integrations/HuduIntegrationSettings.tsx; the page loads it via dynamic(() => import('@enterprise/components/settings/integrations/HuduIntegrationSettings'))@enterprise is already edition-swapped in next.config (ee/server/src vs packages/ee/src), so NO new aliases/routes needed. CE delegator route untouched (still 3/3).
  • Page: IntegrationsSettingsPage.tsx gets an it-documentation category (icon BookOpen, single hudu item, isEE) spread-gated on useHuduIntegrationEnabled().enabled; calendarAvailability.getVisibleIntegrationCategoryIds EE branch now includes IT_DOCUMENTATION_SETTINGS_CATEGORY = 'it-documentation' (CE list unchanged → CE can never show it).
  • F033 additive huduActions change: connectHudu takes HuduConnectInput (apiKey?) — blank key falls back to stored key via resolveHuduCredentials, error unchanged when neither exists; testHuduConnection merges partial candidates with stored creds; failure arm of HuduActionResult gains optional errorKind (set from validation error) so the UI maps 401→invalid-key / 404→bad-base-URL messages. UI only sends apiKey when non-empty.
  • Component: status badge Not connected/Connected/Error (Badge secondary/success/error), detected instance + password-access indicator when connected, inline URL-format validation, useToast (destructive) on failures, key input type=password autoComplete=new-password never prefilled, cleared after connect.
  • i18n: integrations.categories.itDocumentation + integrations.items.hudu in server/public/locales/en/msp/settings.json; integrations.hudu.settings.* in en/msp/integrations.json. Component uses NinjaOne-style t(key, { defaultValue }).
  • Tests (ee/server vitest, jsdom + @testing-library, entra-test house style): huduSettingsPageCategory.test.tsx (T030, 3 cases — gate on/off/CE; mocks sibling components via @alga-psa/integrations/... specifiers that dedupe with relative imports) + huduIntegrationSettings.component.test.tsx (T031T034, 15 cases). 3 keep-existing-key cases added to huduConnectionActions.test.ts (one strict-equality assertion extended for errorKind). All 67 hudu tests green.
  • Harness gotchas (REUSE): mock @alga-psa/ui/lib/i18n/client with a STABLE t identity (new-per-render t retriggers useCallback'd loaders → infinite loading flicker); added vitest alias @product/billing/entrypackages/product-billing/oss/entry.tsx in ee/server/vitest.config.ts (page's Stripe dynamic import broke Vite transform); jest-dom matcher TYPES don't resolve in ee/server (nested vitest 4.1.5 vs root 4.0.18 — pre-existing, entra tests have same errors) → use plain assertions (toBeTruthy/.disabled/textContent); entraIntegrationSettings.initialSyncCta.test.tsx & co fail PRE-EXISTING on this branch (i18n strings unresolved) — verified identical 22-file/50-test failure set with changes stashed.

company-mapping-data (F040F046, T040T048) — DONE + verified 2026-06-09 (uncommitted)

  • ee/server/src/lib/integrations/hudu/companyMapping.ts: matcher + cache shaping (pure) AND knex-level persistence/resolvers (all (knex, tenant, ...), table via HUDU_MAPPING_TABLE); ee/server/src/lib/actions/integrations/huduMappingActions.ts ('use server', own copy of withHuduSettingsAccess — can't export a sync wrapper from a 'use server' file): syncHuduCompanies (update), getHuduCompanyMappings (read), setHuduCompanyMapping/clearHuduCompanyMapping (update), resolveHuduCompanyIdForClient/resolveClientIdForHuduCompany (read). Barrel updated.
  • Matcher: per-company priority id_in_integration string-equals client_id (1.0) → exact case-insensitive name (0.9) → fuzzy normalized-Levenshtein ≥ 0.8 (score); already-mapped companies/clients excluded; greedy one-to-one claiming (best pass/score wins a client). Cache shape: settings.companies_cache = { companies: [{id,name,id_in_integration:string|null,url}], fetched_at: ISO } — read-modify-write merge preserves password_access.
  • SCHEMA FACTS (verified live): shared table column is tenant (NOT tenant_id — renamed by 20250512094730_standardize_tenant_columns), PK (id, tenant); BOTH one-to-one directions already DB-enforced (idx_unique_alga_mapping + idx_unique_external_mapping w/ COALESCE(realm,'')); sync_status is unconstrained varchar(20) → 'manual_link' used. Pre-checks give friendly typed errors (client_already_mapped/company_already_mapped); racing 23505 → mapping_conflict. Replace = explicit clear+set only.
  • Tests (97 hudu tests green across 10 files): unit/huduCompanyMatcher.test.ts (T041T043, pure), unit/huduMappingActions.test.ts (T040/T046 — partial vi.mock of companyMapping via importOriginal keeps matcher real, fakes row fns), integration/hudu-company-mappings.integration.test.ts (T044/T045/T047/T048 — real DB :5432, random-uuid tenant + 2 clients fixtures, full cleanup, beforeEach wipes tenant's mapping rows for shuffle-safety).
  • Gotchas: non-strict ee tsconfig doesn't narrow result.ok unions in SOURCE either → Extract<HuduMappingWriteResult, { ok: false }> cast in the action; vitest 4 importOriginal is untyped → cast the awaited result, not a type argument. Levenshtein note: 'Acme Corp' vs 'Acme Corporation' is only ~0.56 — pick near-variants (≤20% edits) for fuzzy fixtures. Pre-existing (NOT this group): huduSettingsPageCategory.test.tsx(28) TS2347.

company-mapping-ui (F050F053, T050T053) — DONE + verified 2026-06-09 (uncommitted)

  • ee/server/src/components/settings/integrations/hudu/HuduCompanyMappingManager.tsx modeled on NinjaOne OrganizationMappingManager (Card + counters + table + per-row ClientPicker + Refresh via useTransition). NO CE stub: like ninjaone/ (no packages/ee/.../ninjaone dir exists), the manager is only imported relatively by the EE-injected HuduIntegrationSettings, which now renders it in a mt-6 sibling div below the connection card when isConnected (the action sets connected = row.is_active, so connected already implies active).
  • Row state from getHuduCompanyMappings views: status = mapping→Mapped(success) / suggestion→Suggested(primary) / else Unmapped(warning); counters mapped/suggested/unmapped/total (hudu-mapping-count-*). Picker pre-fill mapping?.client_id ?? suggestion?.client_id; suggested rows get a source+confidence note (hudu-mapping-suggestion-<id>). Select → setHuduCompanyMapping (metadata from the row), clear → clearHuduCompanyMapping({mappingId}), change on a mapped row = explicit clear-then-set (server rejects overwrites); typed codes (client_already_mapped etc.) map to friendly messages + destructive toast. Refresh = syncHuduCompanies then loadData() (mappings untouched server-side). Clients via getAllClients(false) from @alga-psa/clients/actions.
  • i18n: integrations.hudu.mapping.* added to server/public/locales/en/msp/integrations.json; component uses t(key, { defaultValue }) throughout.
  • Tests: unit/huduCompanyMappingManager.component.test.tsx (T050T053, 9 cases — mapping actions + @alga-psa/clients/actions mocked, ClientPicker mocked as a plain <select> ('' = null), same Card/Badge/Button/Alert/toast/i18n mock idioms); settings test gains the manager stub mock + 2 embedding cases (renders when connected, absent otherwise). All 9 hudu unit files green (101 tests). Focused tsc: 0 errors in touched files (the only hudu hit is the PRE-EXISTING huduSettingsPageCategory.test.tsx(28) TS2347 — re-verified with changes stashed).
  • Gotchas (REUSE): testing-library getByText matches DIRECT text nodes, but client names appear in every picker mock's <option>s too — assert row textContent instead; fireEvent.change to the already-selected value does NOT fire React onChange (value tracking), so "confirm the suggested client" can't be simulated by re-selecting it.

company-mapping-ui (F050F053, T050T053) — DONE + verified 2026-06-09, committed

  • ee/server/src/components/settings/integrations/hudu/HuduCompanyMappingManager.tsx — NinjaOne OrganizationMappingManager pattern: counters, per-row ClientPicker (pre-filled with mapping or suggestion), Mapped/Suggested/Unmapped badges, Refresh Companies. Rendered inside HuduIntegrationSettings when connected (sibling div, NinjaOne precedent — no CE stub needed: only imported relatively by the EE-injected parent).
  • Mapped-row change = explicit clear-then-set (server rejects overwrites); typed conflict codes → friendly toast.
  • Gotcha fixed: cross-file Postgres deadlock (40P01) — the migration test's DROP TABLE hudu_integrations (needs lock on tenants for the FK) vs the mapping test's DELETE FROM tenants (cascade needs lock on hudu_integrations) when vitest ran both files in parallel. Fix: both hudu integration test files take pg_advisory_lock(hashtext('hudu-db-integration-tests')) in beforeAll on a single-connection pool (pool:{min:1,max:1}), unlock_all+destroy in afterAll. Full suite 108/108 ×3.

reference-fetch (F060F068, T060T069) — DONE + verified 2026-06-09 (uncommitted)

  • ee/server/src/lib/integrations/hudu/referenceData.ts: capped-FIFO module Map cache keyed ${tenant}:${companyId}:${resource} (TTL 60s, cap 200, lazy expiry; externalMappingActions pattern), allowlist toHuduAssetPasswordSummary (only id/company_id/name/username/url/password_folder_name/description/timestamps — password/otp_secret/unknown fields can never pass), deep-link builders buildHuduRecordUrl (record url absolute as-is / relative resolved via huduInstanceBaseUrl) + buildHuduCompanyUrl (company url → /companies/jump API URL only when id_in_integration+slug known → null). contracts.ts gains explicit otp_secret and Summary = Omit<…,'password'|'otp_secret'>.
  • ee/server/src/lib/actions/integrations/huduDataActions.ts ('use server', own withHuduSettingsAccess copy): getHuduCompanyAssets/Articles/Passwords(clientId, {refresh?}) + revealHuduPassword(clientId, huduPasswordId) — ALL read-gated (PRD flow 4 = technician view; reveal's compensating control is the mandatory audit, not a harder gate). Shared fetchCompanyList: row-level resolver (unmapped ⇒ {state:'unmapped'} before ANY Hudu call), cache-or-fetch, project BEFORE cache (passwords stripped pre-cache), per-record hudu_url = record url → company url (from companies_cache + base_url) → null. 403 ⇒ {state:'no_password_access'}; HuduRequestError ⇒ {state:'error',errorKind}.
  • Reveal: single getAssetPassword(id) GET, company_id must equal the mapped company (else not_found — no cross-company leak), 404→not_found, 403→no_password_access; audit BEFORE value, fail-closed (audit throw ⇒ error state, no value); value never cached/persisted/logged (logger gets ids only). Read actions stay write-free (no touch…LastSynced on fetch — same as the mapping read path).
  • Audit sink ee/server/src/lib/integrations/hudu/revealAudit.ts: shared audit_logs table via auditLog from server/src/lib/logging/auditLog (EE precedent: ninjaoneActions REMOTE_ACCESS audit) — BUT auditLog silently SKIPS when the app.current_tenant GUC is unset (new pool model never sets it), so wrap in knex.transaction + set_config('app.current_tenant', tenant, true) first (expiredCreditsHandler precedent; audit_logs trigger stamps tenant from the GUC). Row: operation hudu_password_reveal, table clients, record clientId, details {integration,tenant,hudu_password_id,hudu_company_id,revealed_at} — never the value.
  • Tests (141 hudu tests green across 14 files, ×2 shuffled seeds): unit/huduReferenceData.test.ts (cache TTL/keying/eviction via fake timers, stripping, deep links), unit/huduRevealAudit.test.ts (GUC-before-auditLog ordering, payload key audit, fail-closed propagation), unit/huduDataActions.test.ts (T060T063/T065T069; partial importOriginal mocks keep HuduRequestError + parseCompaniesCache + the REAL referenceData cache; asserts client-never-called on unmapped, single-GET reveal with zero repo/Vault/knex/cache writes and value absent from every logger+console call).
  • Gotcha (REUSE): static-importing a partially-mocked module (vi.mock(..., importOriginal)) from the test file TDZ-crashes on the factory's closure over mock consts — use top-level await import(...) after the const declarations instead.

client-hudu-tab (F070F072, F074, F075; T070/T071/T073/T074) — DONE + verified 2026-06-09 (uncommitted)

  • Tab mechanism (no prior client-page extension point existed; combined the two precedents): real EE tab ee/server/src/components/integrations/hudu/HuduClientTab.tsx (imports huduDataActions relatively); CE null stub packages/ee/src/components/integrations/hudu/HuduClientTab.tsx; CE wrapper packages/clients/src/components/clients/HuduClientTab.tsx = dynamic(() => import('@enterprise/components/...'), {ssr:false}) (AssetAlertsSection precedent, packages/assets); registered in ClientDetails.tsx baseTabContent (id hudu, spread-gated, huduClientTab.visible in the useMemo deps).
  • Gate packages/clients/src/components/clients/useHuduClientTab.ts: isEnterprise (@alga-psa/core) + useFeatureFlag('hudu-integration') (useHuduIntegrationEnabled logic INLINED — packages/integrations has no exported barrel path for it and adding the package dep wasn't worth it), then dynamic await import('@enterprise/lib/actions/integrations/huduDataActions') → new getHuduClientContext(clientId){connected, mapped}; any throw ⇒ hidden. New CE action stub packages/ee/src/lib/actions/integrations/huduDataActions.ts returns {connected:false,mapped:false} (plain async fn, not 'use server').
  • getHuduClientContext (additive in EE huduDataActions, read-gated): is_active row check → resolver; NO Hudu API call; internal failure resolves {false,false} instead of throwing (UI-gate semantics).
  • Tab component: attribution always visible ("Source: Hudu" + Open-in-Hudu link from ok-result companyUrl, F075); Refresh re-runs context+both fetches with {refresh:true} (F074); guard states not-connected/unmapped (context OR fetch-level state:'unmapped') + tab-level unreachable (context throw) + per-section unreachable alerts (state:'error'|'no_password_access'); rows = name link (target=_blank rel=noopener noreferrer, plain span when hudu_url null) + meta (asset_type · Serial; articles only have folder_id in the Hudu payload so meta shows "Folder #N" — no folder NAME exists in the API).
  • i18n: integrations.hudu.clientTab.* in en/msp/integrations.json (component, ns 'msp/integrations'); tab LABEL is clientDetails.huduTab in en/msp/clients.json (ClientDetails uses ns 'msp/clients').
  • Tests: unit/huduClientTab.component.test.tsx (T070 guard/T071/T073/T074 + refresh, 12 cases, mapping-manager idioms, actions mocked via @ee/...); unit/huduClientTabGate.test.tsx (T070 registration gate, 7 cases — mocks @alga-psa/ui/hooks, @alga-psa/core hoisted-getter isEnterprise, @enterprise/lib/actions/...; SUT imported via @alga-psa/clients/components/... alias); +5 getHuduClientContext cases in huduDataActions.test.ts. Full hudu suite 16 files / 165 tests green ×2 seeds.
  • Verification: packages/clients tsc --noEmit 0 errors; ee/server tsc → only pre-existing 3 (huduSettingsPageCategory TS2347 + 2 msp-composition TS2307); packages/clients vitest: 2 pre-existing contacts failures (ContactDetails.inboundDestination.wiring, ContactPortalTab.visibilityGroups) — identical with changes stashed; tsup build keeps @enterprise specifiers in dist (entraClientSyncActions precedent — webpack swaps per edition).

client-passwords-tab (F080F083; T080T083) — DONE + verified 2026-06-09 (uncommitted)

  • Mirrors the client-hudu-tab trio exactly: real EE tab ee/server/src/components/integrations/hudu/HuduClientPasswordsTab.tsx (relative huduDataActions imports); CE null stub packages/ee/src/components/integrations/hudu/HuduClientPasswordsTab.tsx; CE wrapper packages/clients/src/components/clients/HuduClientPasswordsTab.tsx (dynamic(@enterprise/..., {ssr:false})). Registered in ClientDetails.tsx as a SECOND entry (id: 'hudu-passwords') inside the SAME huduClientTab.visible spread, directly after hudu — no new gate hook, REUSES useHuduClientTab/getHuduClientContext.
  • Reveal lifecycle (NFR1): values live ONLY in a revealedValues: Record<id,string> useState — set by revealHuduPassword(clientId, id) on {state:'ok'}, deleted on Hide, the whole map reset at the top of every load() (so Refresh clears it), gone on unmount; Copy = navigator.clipboard.writeText from that state; never logged. Per-row inline reveal errors keyed no_password_access/not_found/else→failed; per-row Open-in-Hudu uses the list's hudu_url (record url → company url fallback from the data layer).
  • States: loading / tab-level unreachable (context throw) / not-connected / unmapped (context OR fetch-level) / no_password_access (DISTINCT key-lacks-password-access alert, id hudu-passwords-tab-no-access) / list error unreachable / ok-empty / ok-rows (name + username meta + count badge; metadata only).
  • i18n: integrations.hudu.passwordsTab.* (source/openInHudu/refresh/loading/notConnected/unmapped/unreachable/noPasswordAccess/title/empty/reveal/hide/copy/revealNoAccess/revealNotFound/revealFailed) in en/msp/integrations.json; label clientDetails.huduPasswordsTab ("Passwords") in en/msp/clients.json.
  • Tests: unit/huduClientPasswordsTab.component.test.tsx (T081T083, 18 cases — value absent pre-reveal, reveal/Hide/Refresh clear the DOM, clipboard mock, afterEach console-spy asserts the secret never hits any console channel) + unit/huduClientPasswordsTabGate.test.tsx (T080, 7 cases — gate-mock TabsProbe mirrors the gated spread asserting BOTH tabs absent/present + ordering, plus a ClientDetails source-wiring case). Gotcha: under jsdom new URL(..., import.meta.url) is NOT file-scheme — import the source via Vite '...ClientDetails.tsx?raw' (alias regex passes the query through) instead of readFileSync. Full hudu suite 16 ee/server files / 183 tests ×2 shuffled seeds + useHuduIntegrationEnabled (4) + huduRouteDelegator (3) = 190 green.
  • Verification: packages/clients tsc --noEmit 0 errors; ee/server tsc → only the same pre-existing 3; packages/clients tsup build OK, dist keeps the @enterprise/components/.../HuduClientPasswordsTab specifier; both packages/clients ClientDetails tests still green.

permissions (F090/F092; T090/T091/T093) — DONE + verified 2026-06-09 (uncommitted)

  • Mostly VERIFICATION: full audit of every Hudu server entry point found ZERO gaps — no source change needed. EE route GET /api/integrations/hudurequireHuduUiFlagEnabled('read'); CE delegator → assertSessionProductAccess + @enterprise lazy import (501 stub otherwise); all 15 exported actions wrapped in withHuduSettingsAccess (the three per-file copies are byte-identical: client-user reject → hasPermission(system_settings, level)assertTierAccess(INTEGRATIONS)assertAddOnAccess(ENTERPRISE)hudu-integration flag). Levels: update = connectHudu/testHuduConnection/disconnectHudu/syncHuduCompanies/setHuduCompanyMapping/clearHuduCompanyMapping; read = getHuduConnectionStatus/getHuduCompanyMappings/both resolvers/getHuduClientContext/getHuduCompanyAssets/Articles/Passwords/revealHuduPassword (reveal stays read per plan — compensating control is the fail-closed audit).
  • New unit/huduPermissions.test.ts (46 cases): completeness case derives runtime function exports of all three action modules and asserts they EQUAL the manageview lists (adding an unlisted action later fails T093); T090 it.each over the 6 manage actions with read-granted/update-denied hasPermission (catches mis-gating at read); T091 it.each over the 9 view entries with update-granted/read-denied (catches over-gating at update); T093 it.each ×15 flag-off (perm granted) + it.each ×15 add-on-denied (asserts flag never consulted); every denial also asserts no createTenantKnex/secret-provider work happened. Route-guard path NOT duplicated — already huduFlagGuard.test.ts (T001) + huduRouteDelegator.test.ts.
  • Gate-test trick (REUSE): mockImplementation((_u,_r,perm) => perm !== 'update') instead of blanket-false makes the permission-LEVEL assertions real — blanket-false would pass even if an action gated at the wrong level.
  • Verified: full hudu ee/server suite 19 files / 236 tests green ×2 shuffled seeds (1111, 987654) incl. both real-DB integration files; + huduRouteDelegator (3) + useHuduIntegrationEnabled (4) green; ee/server tsc → only the same pre-existing 3 errors (huduSettingsPageCategory TS2347 + 2 msp-composition TS2307), zero touching huduPermissions.

i18n (F100/T100) — DONE + verified 2026-06-09 (uncommitted)

  • Pure VERIFICATION group: zero i18n gaps found — every key already resolves, no hardcoded strings. New static test unit/huduI18n.test.ts (12 cases), no source/locale changes.
  • Key resolution: collects every t('…') literal via regex from the 4 Hudu components (Vite ?raw imports — huduClientPasswordsTabGate precedent) + ClientDetails (the 2 clientDetails.hudu* labels) + IntegrationsSettingsPage (integrations.categories.itDocumentation.*/integrations.items.hudu.*), filtered to the Hudu key families, and asserts each resolves to a non-empty string in its namespace file (msp/integrations→integrations.json, msp/clients→clients.json, msp/settings→settings.json). Per-source minKeys lower bounds guard the extraction regex against rot; missing keys fail by name.
  • Hardcoded-string heuristic (documented in-file): strip comments → candidate text nodes = (?<!=)>([^<>{}]+)<(?=[A-Za-z/]) (brace-free runs between a tag-> and a tag-<; translated text always renders via {t(…)} so literal nodes are brace-free) → reject code-shaped candidates ([;=()\]— generics/statements between><` were the false-positive source) → flag >2 words (3+ runs of 2+ letters). Plus literal multi-word label/placeholder/title/alt/aria-label attributes. Self-test case pins the heuristic. Allowlist const provided (currently empty).

regression (T110T114) — DONE + verified 2026-06-09 (uncommitted)

  • New unit/huduRegression.test.ts (21 cases) + integration/hudu-regression.integration.test.ts (T112 DB half, same advisory-lock + single-conn-pool harness/key as the other two DB files).
  • Real gap found + minimally fixed (T111): disconnect did NOT clear the reference cache — reconnect with a new key within the 60s TTL could serve keyA-era lists. Fix: clearHuduReferenceCacheForTenant(tenant) added to referenceData.ts (tenant-prefix delete), called from disconnectHudu after setActive(false). Lifecycle test proves connect(keyA)→fetch(cached)→disconnect(secrets+cache gone)→connect(keyB)→fetch WITHOUT refresh is live keyB data, keyA strings absent.
  • T110 static sweep (fs walk for /hudu/i-pathed non-test sources across ee/server/src + server/src + packages, ≥20 files, completeness-pinned): balanced-paren call-arg extraction asserts (a) knex insert/update/merge args have no password/otp token (allowlist: password_access, password_folder_name, hudu_password_id…), (b) setTenantSecret only ever called with HUDU_SECRET_KEYS.apiKey/baseUrl (exactly 1 call site each), (c) setCachedHuduList call sites never pass raw records, (d) logger/console args never reference record/.password/otp_secret/apiKey/value. Behavioral: poisoned record through REAL toHuduAssetPasswordSummary strips password/otp_secret/totp/unknown fields. Reveal-path behavioral guarantees referenced to T067/T068, not duplicated. NOTE: the CE delegator's console.error('… Failed to load EE route') is deliberate (entra precedent) — the console check inspects args, doesn't ban console.
  • T112: DB — tenant A's mapping rows invisible to tenant B list+resolvers; same external company id maps independently per tenant (unique indexes are tenant-scoped) and resolves per tenant. Unit — cache keys tenant-prefixed (poison A ⇒ B miss; same companyId distinct per tenant).
  • T113: it.each network_error over assets/articles/passwords ⇒ {state:'error',errorKind:'network_error'}; reveal same (no value, no audit); failing createHuduClient factory same; getHuduClientContext untouched by Hudu unreachability (no Hudu call).
  • T114: actions re-resolve the mapping EVERY call (resolver consulted per call, no memoization) — clearing the mapping flips the next fetch to unmapped even with a warm cache; context flips mapped:false; reveal blocked (no GET, no audit).
  • Harness gotcha (REUSE): vi.clearAllMocks() keeps mockRejectedValue overrides — restore shared mock default implementations in beforeEach (createHuduClientMock leak made T113 failures bleed into shuffled neighbors).
  • Verified: full hudu ee/server suite 22 files / 271 tests green ×2 shuffled seeds (1111, 987654) incl. all three real-DB files; + huduRouteDelegator (3) + useHuduIntegrationEnabled (4); ee/server tsc → only the same pre-existing 3 errors.