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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
43 KiB
43 KiB
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-integrationfeature flag. - Hudu = MSP IT-documentation platform. REST JSON API. Auth =
x-api-keyheader + 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_slugon 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.tsx—categoriesarray ofIntegrationItem { 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 returnseeUnavailable()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):@enterprise→packages/ee/src,@ee→packages/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 ashudu_api_key,hudu_base_url(per tenant). NinjaOne stores JSON blob under one key — viable too. - Metadata layer:
tenant_secretstable +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 inee/server/migrations/*.cjs(Knex CJS). Tenant tables: PK(tenant uuid, <id> uuid), FKs(tenant, ref),updated_attriggeron_update_timestamp(), Citus-distributed bytenant. App-layer isolation (no RLS). - Connection-state shape precedent:
server/migrations/20251124000001_create_rmm_integration_tables.cjs→rmm_integrations (tenant, integration_id, provider, instance_url, is_active, connected_at, last_sync_at, settings jsonb). BUT location:rmm_integrationsis CE only because the RMM layer is partly CE; Hudu is wholly EE, sohudu_integrationsgoes inee/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(iddocuments-passwords, icon Lock), rendered byAssetDetailView.tsx. - Documents already attach to assets (real) via
document_associations(entity_type='asset'); seepackages/assets/src/components/AssetDocuments.tsx. - NOTE scope mismatch: Hudu
asset_passwordsare 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
secretProvideronly. 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_integrationsis 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 (sharedtenant_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). SosetTenantSecretcan 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:
- 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.
- 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.
- 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).
- Company↔Client matching → reuse generic
tenant_external_entity_mappings(Xero/QBO modern shared table;integration_type='hudu',alga_entity_type='client') with NinjaOneOrganizationMappingManagersingle-table UX. Auto-suggest byid_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=companyexist (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+ CRUDpackages/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 0–1 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), T067–T069/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-tab→client-passwords-tab(F080–F083, T080–T083); removed Hudu-tab passwords section (old F073/T072). - OQ3: reuse existing
system_settingsresource (read=view,update=manage). Evidence: Entra/Teams/Tactical-RMM/SSO all usesystem_settings; only billing/accounting (Xero/QBO +externalMappingActions) usebilling_settings. So Hudu mapping actions writetenant_external_entity_mappingsdirectly gated onsystem_settings— do NOT call thebilling_settings-gatedexternalMappingActionswrappers. 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
huduRBAC resource vs reuse (default: newhuduresource, 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 includecommitGroupper software-planner spec.
Implementation log
scaffold (F001–F005) — DONE + verified 2026-06-09 (uncommitted)
CE↔EE wiring pattern (reuse for ALL later route/lib groups):
@enterprisealias is edition-swapped inserver/next.config.mjs:319-320: EE →ee/server/src, CE →packages/ee/src. (server/tsconfig.jsonstatically points@enterpriseatpackages/ee/srcfor 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 atpackages/ee/src/app/api/integrations/hudu/<route>. The CE entry atserver/src/app/api/integrations/hudu/<route>lazy-imports@enterprise/app/api/integrations/hudu/<route>guarded byisEnterpriseEdition+assertSessionProductAccess, elseeeUnavailable()501. - Files created: EE lib
ee/server/src/lib/integrations/hudu/{contracts.ts,index.ts}; EE routeee/server/src/app/api/integrations/hudu/{_guards.ts,_responses.ts,route.ts}; EE stubpackages/ee/src/app/api/integrations/hudu/{_stub.ts,route.ts}; CE entryserver/src/app/api/integrations/hudu/{_ceStub.ts,route.ts}; client gatepackages/integrations/src/components/settings/integrations/useHuduIntegrationEnabled.ts. - Guard
requireHuduUiFlagEnabled('read'|'update')mirrors EntrarequireEntraUiFlagEnabled: flaghudu-integrationviafeatureFlags.isEnabled, RBACsystem_settings, EE tier+add-on, 404 when flag off. Reuse this guard in every EE Hudu route/action. contracts.tsexportsHUDU_INTEGRATION_TYPE+ value-strippedHuduAssetPasswordSummary(nopasswordfield) for list payloads.- Verified: focused tsc on all 10 files = 0 errors;
tsc -p packages/integrations/tsconfig.jsonclean for our files; remaining server/ee baseline errors are pre-existing and reference no hudu file. - Tests T001–T004 written + green (13 cases). Group committed
eca8008(also includes the plan folder).
hudu-client (F010–F017, T010–T018) — 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 viagetSecretProviderInstance()/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 THROWHuduRequestError(carrying the typed HuduError); consuming actions (connection/reference-fetch groups) map throw→envelope.validateConnection()returns the struct directly. - Backoff
sleepis 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). ReusecreateHuduClient()in later groups; do NOT add OAuth (x-api-key only).
connection (F020–F028, T020–T028) — 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_attrigger (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 inwithHuduSettingsAccess(perm)= withAuth + user_type!=='client' +system_settingsRBAC + assertTierAccess(INTEGRATIONS) + assertAddOnAccess(ENTERPRISE) +hudu-integrationflag (action-level mirror of requireHuduUiFlagEnabled). Capability stored insettings.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.tsaddsHUDU_MAPPING_TABLE = 'tenant_external_entity_mappings'— mapping group MUST use it (NFR7 boundary anchor; boundary test asserts it).- Tests (49 hudu tests green total): T020–T022 REAL DB (
src/__tests__/integration/hudu-integrations.migration.integration.test.ts— direct knex to localserverDB :5432, postgres + secrets/postgres_password, single-migration up()/down() re-apply pattern; tests self-contained because vitest shuffles); T023–T026 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 soif (!result.success)does NOT narrow unions in tests — use toMatchObject.
settings-ui (F030–F034, T030–T034) — 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.tsximportshuduActionsdirectly (relative path, like TaniumIntegrationSettings); CE placeholder stub atpackages/ee/src/components/settings/integrations/HuduIntegrationSettings.tsx; the page loads it viadynamic(() => import('@enterprise/components/settings/integrations/HuduIntegrationSettings'))—@enterpriseis 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.tsxgets anit-documentationcategory (icon BookOpen, singlehuduitem, isEE) spread-gated onuseHuduIntegrationEnabled().enabled;calendarAvailability.getVisibleIntegrationCategoryIdsEE branch now includesIT_DOCUMENTATION_SETTINGS_CATEGORY = 'it-documentation'(CE list unchanged → CE can never show it). - F033 additive huduActions change:
connectHudutakesHuduConnectInput(apiKey?) — blank key falls back to stored key viaresolveHuduCredentials, error unchanged when neither exists;testHuduConnectionmerges partial candidates with stored creds; failure arm ofHuduActionResultgains optionalerrorKind(set from validation error) so the UI maps 401→invalid-key / 404→bad-base-URL messages. UI only sendsapiKeywhen 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 inputtype=passwordautoComplete=new-passwordnever prefilled, cleared after connect. - i18n:
integrations.categories.itDocumentation+integrations.items.huduinserver/public/locales/en/msp/settings.json;integrations.hudu.settings.*inen/msp/integrations.json. Component uses NinjaOne-stylet(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(T031–T034, 15 cases). 3 keep-existing-key cases added tohuduConnectionActions.test.ts(one strict-equality assertion extended forerrorKind). All 67 hudu tests green. - Harness gotchas (REUSE): mock
@alga-psa/ui/lib/i18n/clientwith a STABLEtidentity (new-per-render t retriggers useCallback'd loaders → infinite loading flicker); added vitest alias@product/billing/entry→packages/product-billing/oss/entry.tsxin 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 (F040–F046, T040–T048) — 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 viaHUDU_MAPPING_TABLE);ee/server/src/lib/actions/integrations/huduMappingActions.ts('use server', own copy ofwithHuduSettingsAccess— 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 preservespassword_access. - SCHEMA FACTS (verified live): shared table column is
tenant(NOTtenant_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_mappingw/ COALESCE(realm,''));sync_statusis 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(T041–T043, 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.okunions in SOURCE either →Extract<HuduMappingWriteResult, { ok: false }>cast in the action; vitest 4importOriginalis 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 (F050–F053, T050–T053) — DONE + verified 2026-06-09 (uncommitted)
ee/server/src/components/settings/integrations/hudu/HuduCompanyMappingManager.tsxmodeled on NinjaOneOrganizationMappingManager(Card + counters + table + per-rowClientPicker+ Refresh viauseTransition). NO CE stub: likeninjaone/(nopackages/ee/.../ninjaonedir exists), the manager is only imported relatively by the EE-injectedHuduIntegrationSettings, which now renders it in amt-6sibling div below the connection card whenisConnected(the action setsconnected = row.is_active, so connected already implies active).- Row state from
getHuduCompanyMappingsviews: status = mapping→Mapped(success) / suggestion→Suggested(primary) / else Unmapped(warning); counters mapped/suggested/unmapped/total (hudu-mapping-count-*). Picker pre-fillmapping?.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_mappedetc.) map to friendly messages + destructive toast. Refresh =syncHuduCompaniesthenloadData()(mappings untouched server-side). Clients viagetAllClients(false)from@alga-psa/clients/actions. - i18n:
integrations.hudu.mapping.*added toserver/public/locales/en/msp/integrations.json; component usest(key, { defaultValue })throughout. - Tests:
unit/huduCompanyMappingManager.component.test.tsx(T050–T053, 9 cases — mapping actions +@alga-psa/clients/actionsmocked, 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-EXISTINGhuduSettingsPageCategory.test.tsx(28)TS2347 — re-verified with changes stashed). - Gotchas (REUSE): testing-library
getByTextmatches DIRECT text nodes, but client names appear in every picker mock's<option>s too — assert rowtextContentinstead;fireEvent.changeto 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 (F050–F053, T050–T053) — 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 ontenantsfor the FK) vs the mapping test'sDELETE FROM tenants(cascade needs lock onhudu_integrations) when vitest ran both files in parallel. Fix: both hudu integration test files takepg_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 (F060–F068, T060–T069) — 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), allowlisttoHuduAssetPasswordSummary(only id/company_id/name/username/url/password_folder_name/description/timestamps —password/otp_secret/unknown fields can never pass), deep-link buildersbuildHuduRecordUrl(record url absolute as-is / relative resolved viahuduInstanceBaseUrl) +buildHuduCompanyUrl(company url →/companies/jumpAPI URL only when id_in_integration+slug known → null).contracts.tsgains explicitotp_secretand Summary =Omit<…,'password'|'otp_secret'>.ee/server/src/lib/actions/integrations/huduDataActions.ts('use server', ownwithHuduSettingsAccesscopy):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). SharedfetchCompanyList: row-level resolver (unmapped ⇒{state:'unmapped'}before ANY Hudu call), cache-or-fetch, project BEFORE cache (passwords stripped pre-cache), per-recordhudu_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 (elsenot_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 (notouch…LastSyncedon fetch — same as the mapping read path). - Audit sink
ee/server/src/lib/integrations/hudu/revealAudit.ts: sharedaudit_logstable viaauditLogfromserver/src/lib/logging/auditLog(EE precedent: ninjaoneActions REMOTE_ACCESS audit) — BUT auditLog silently SKIPS when theapp.current_tenantGUC is unset (new pool model never sets it), so wrap inknex.transaction+set_config('app.current_tenant', tenant, true)first (expiredCreditsHandler precedent; audit_logs trigger stamps tenant from the GUC). Row: operationhudu_password_reveal, tableclients, 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(T060–T063/T065–T069; 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-levelawait import(...)after the const declarations instead.
client-hudu-tab (F070–F072, 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 stubpackages/ee/src/components/integrations/hudu/HuduClientTab.tsx; CE wrapperpackages/clients/src/components/clients/HuduClientTab.tsx=dynamic(() => import('@enterprise/components/...'), {ssr:false})(AssetAlertsSection precedent, packages/assets); registered inClientDetails.tsxbaseTabContent(idhudu, spread-gated,huduClientTab.visiblein 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 dynamicawait import('@enterprise/lib/actions/integrations/huduDataActions')→ newgetHuduClientContext(clientId)→{connected, mapped}; any throw ⇒ hidden. New CE action stubpackages/ee/src/lib/actions/integrations/huduDataActions.tsreturns{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-levelstate:'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 havefolder_idin the Hudu payload so meta shows "Folder #N" — no folder NAME exists in the API). - i18n:
integrations.hudu.clientTab.*inen/msp/integrations.json(component, ns 'msp/integrations'); tab LABEL isclientDetails.huduTabinen/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/corehoisted-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 --noEmit0 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@enterprisespecifiers in dist (entraClientSyncActions precedent — webpack swaps per edition).
client-passwords-tab (F080–F083; T080–T083) — 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 stubpackages/ee/src/components/integrations/hudu/HuduClientPasswordsTab.tsx; CE wrapperpackages/clients/src/components/clients/HuduClientPasswordsTab.tsx(dynamic(@enterprise/..., {ssr:false})). Registered inClientDetails.tsxas a SECOND entry (id: 'hudu-passwords') inside the SAMEhuduClientTab.visiblespread, directly afterhudu— no new gate hook, REUSESuseHuduClientTab/getHuduClientContext. - Reveal lifecycle (NFR1): values live ONLY in a
revealedValues: Record<id,string>useState — set byrevealHuduPassword(clientId, id)on{state:'ok'}, deleted on Hide, the whole map reset at the top of everyload()(so Refresh clears it), gone on unmount; Copy =navigator.clipboard.writeTextfrom 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'shudu_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, idhudu-passwords-tab-no-access) / listerrorunreachable / 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) inen/msp/integrations.json; labelclientDetails.huduPasswordsTab("Passwords") inen/msp/clients.json. - Tests:
unit/huduClientPasswordsTab.component.test.tsx(T081–T083, 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 jsdomnew 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 --noEmit0 errors; ee/server tsc → only the same pre-existing 3; packages/clients tsup build OK, dist keeps the@enterprise/components/.../HuduClientPasswordsTabspecifier; 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/hudu→requireHuduUiFlagEnabled('read'); CE delegator →assertSessionProductAccess+@enterpriselazy import (501 stub otherwise); all 15 exported actions wrapped inwithHuduSettingsAccess(the three per-file copies are byte-identical: client-user reject →hasPermission(system_settings, level)→assertTierAccess(INTEGRATIONS)→assertAddOnAccess(ENTERPRISE)→hudu-integrationflag). 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 manage∪view lists (adding an unlisted action later fails T093); T090 it.each over the 6 manage actions with read-granted/update-deniedhasPermission(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 nocreateTenantKnex/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?rawimports — huduClientPasswordsTabGate precedent) + ClientDetails (the 2clientDetails.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 (T110–T114) — 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 fromdisconnectHuduafter 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
unmappedeven with a warm cache; context flips mapped:false; reveal blocked (no GET, no audit). - Harness gotcha (REUSE):
vi.clearAllMocks()keepsmockRejectedValueoverrides — 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.