@openhi/constructs 0.0.117 → 0.0.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,7 @@ import {
15
15
  getDynamoDataService
16
16
  } from "./chunk-U7L7T4XU.mjs";
17
17
  import {
18
+ ConflictError,
18
19
  ValidationError
19
20
  } from "./chunk-FYHBHHWK.mjs";
20
21
  import {
@@ -42,6 +43,19 @@ function extractDenormalizedReferenceDisplay(resource, fieldName) {
42
43
  return trimmed.length > 0 ? trimmed : void 0;
43
44
  }
44
45
 
46
+ // src/data/operations/control/membership-constraints/assert-workspace-in-tenant-operation.ts
47
+ async function assertWorkspaceInTenantOperation(params) {
48
+ const { tenantId, workspaceId, tableName } = params;
49
+ const service = getDynamoControlService(tableName);
50
+ const { data: item } = await service.entities.workspace.get({ tenantId, id: workspaceId, sk: "CURRENT" }).go();
51
+ if (!item) {
52
+ throw new ConflictError(
53
+ `Workspace ${workspaceId} does not belong to tenant ${tenantId}; the workspace must be created in the referenced tenant before this resource can reference it.`,
54
+ { details: { tenantId, workspaceId } }
55
+ );
56
+ }
57
+ }
58
+
45
59
  // src/data/operations/control/membership/membership-create-operation.ts
46
60
  async function createMembershipOperation(params) {
47
61
  const { context, body, tableName } = params;
@@ -82,6 +96,15 @@ async function createMembershipOperation(params) {
82
96
  resourceRecord,
83
97
  "workspace"
84
98
  );
99
+ if (workspaceIdFromResource !== void 0) {
100
+ const tenantIdFromResource = extractReferenceSlug(resourceRecord, "tenant");
101
+ const referencedTenantId = tenantIdFromResource ?? context.tenantId;
102
+ await assertWorkspaceInTenantOperation({
103
+ tenantId: referencedTenantId,
104
+ workspaceId: workspaceIdFromResource,
105
+ tableName
106
+ });
107
+ }
85
108
  const userProjectionItem = userIdFromResource !== void 0 ? buildMembershipUserProjectionItem({
86
109
  tenantId: context.tenantId,
87
110
  userId: userIdFromResource,
@@ -142,6 +165,23 @@ async function createMembershipOperation(params) {
142
165
 
143
166
  // src/data/operations/control/roleassignment/roleassignment-create-operation.ts
144
167
  import { extractSummary as extractSummary2 } from "@openhi/types";
168
+
169
+ // src/data/operations/control/membership-constraints/assert-user-has-tenant-membership-operation.ts
170
+ var TENANT_LANE_SK_PREFIX = "MEMBERSHIP#TENANT#";
171
+ async function assertUserHasTenantMembershipOperation(params) {
172
+ const { userId, tenantId, tableName } = params;
173
+ const service = getDynamoControlService(tableName);
174
+ const result = await service.entities.membershipUserProjection.query.record({ userId }).begins({ sk: TENANT_LANE_SK_PREFIX }).go();
175
+ const matched = (result.data ?? []).some((row) => row.tenantId === tenantId);
176
+ if (!matched) {
177
+ throw new ConflictError(
178
+ `User ${userId} has no tenant-level Membership in tenant ${tenantId}; a Membership must exist before a RoleAssignment can be created.`,
179
+ { details: { userId, tenantId } }
180
+ );
181
+ }
182
+ }
183
+
184
+ // src/data/operations/control/roleassignment/roleassignment-create-operation.ts
145
185
  async function createRoleAssignmentOperation(params) {
146
186
  const { context, body, tableName } = params;
147
187
  const service = getDynamoControlService(tableName);
@@ -170,6 +210,22 @@ async function createRoleAssignmentOperation(params) {
170
210
  resourceRecord,
171
211
  "workspace"
172
212
  );
213
+ if (userIdFromResource !== void 0) {
214
+ const tenantIdFromResource = extractReferenceSlug2(resourceRecord, "tenant");
215
+ const referencedTenantId = tenantIdFromResource ?? context.tenantId;
216
+ await assertUserHasTenantMembershipOperation({
217
+ userId: userIdFromResource,
218
+ tenantId: referencedTenantId,
219
+ tableName
220
+ });
221
+ if (workspaceIdFromResource !== void 0) {
222
+ await assertWorkspaceInTenantOperation({
223
+ tenantId: referencedTenantId,
224
+ workspaceId: workspaceIdFromResource,
225
+ tableName
226
+ });
227
+ }
228
+ }
173
229
  const userProjectionItem = userIdFromResource !== void 0 && roleIdFromResource !== void 0 ? buildRoleAssignmentUserProjectionItem({
174
230
  tenantId: context.tenantId,
175
231
  userId: userIdFromResource,
@@ -330,4 +386,4 @@ export {
330
386
  createTenantOperation,
331
387
  createWorkspaceOperation
332
388
  };
333
- //# sourceMappingURL=chunk-QWWLM452.mjs.map
389
+ //# sourceMappingURL=chunk-7WDX6GPO.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/data/operations/control/membership/membership-create-operation.ts","../src/data/operations/control/denormalized-display-names.ts","../src/data/operations/control/membership-constraints/assert-workspace-in-tenant-operation.ts","../src/data/operations/control/roleassignment/roleassignment-create-operation.ts","../src/data/operations/control/membership-constraints/assert-user-has-tenant-membership-operation.ts","../src/data/operations/control/tenant/tenant-create-operation.ts","../src/data/operations/control/workspace/workspace-create-operation.ts","../src/data/operations/data/organization/organization-provision-for-workspace-operation.ts"],"sourcesContent":["import {\n assertLinkedDataIdentityCardinality,\n extractSummary,\n type Extension,\n type FhirResourceLike,\n LinkedDataIdentityCardinalityError,\n} from \"@openhi/types\";\nimport {\n buildMembershipUserProjectionItem,\n extractReferenceSlug,\n} from \"./membership-user-projection\";\nimport { buildMembershipWorkspaceProjectionItem } from \"./membership-workspace-projection\";\nimport { getDynamoControlService } from \"../../../dynamo/dynamo-control-service\";\nimport { ValidationError } from \"../../../errors\";\nimport { OpenHiContext } from \"../../../openhi-context\";\nimport { extractDenormalizedReferenceDisplay } from \"../denormalized-display-names\";\nimport { assertWorkspaceInTenantOperation } from \"../membership-constraints/assert-workspace-in-tenant-operation\";\nimport {\n executeMultiWrite,\n type MultiWriteTriple,\n} from \"../multi-write-operation\";\n\nexport interface MembershipCreateParams {\n context: OpenHiContext;\n body: { id?: string; resource?: Record<string, unknown> | string };\n tableName?: string;\n}\n\nexport interface MembershipCreateResult {\n id: string;\n resource: { resourceType: string; id: string; [key: string]: unknown };\n meta: { lastUpdated: string; versionId: string };\n}\n\nexport async function createMembershipOperation(\n params: MembershipCreateParams,\n): Promise<MembershipCreateResult> {\n const { context, body, tableName } = params;\n const service = getDynamoControlService(tableName);\n\n const id = body.id ?? `membership-${Date.now()}`;\n const parsedResource =\n typeof body.resource === \"string\"\n ? (JSON.parse(body.resource) as Record<string, unknown>)\n : (body.resource ?? {});\n\n const lastUpdated = context.date ?? new Date().toISOString();\n const vid = `1`;\n\n const resource = { resourceType: \"Membership\", id, ...parsedResource };\n\n let linkedDataIdentityRef: string | undefined;\n try {\n const ext = assertLinkedDataIdentityCardinality(\n resource as { extension?: Array<Extension> },\n );\n linkedDataIdentityRef = ext?.valueReference?.reference;\n } catch (e) {\n if (e instanceof LinkedDataIdentityCardinalityError) {\n throw new ValidationError(e.message, { cause: e });\n }\n throw e;\n }\n\n // TR-024 denormalized display-name attributes. The authoritative\n // write-time source per TR-024 rule 2 is the canonical Tenant / User\n // record's `displayName`. Until the carrier-record-lookup pass lands\n // (#1010 follow-up), the foundational fallback (#1009) reads the\n // FHIR Reference.display values supplied on the resource so the\n // adjacency-list projection writer here can compose SKs from\n // top-level attributes.\n const resourceRecord = resource as Record<string, unknown>;\n const denormalizedTenantName = extractDenormalizedReferenceDisplay(\n resourceRecord,\n \"tenant\",\n );\n const denormalizedUserName = extractDenormalizedReferenceDisplay(\n resourceRecord,\n \"user\",\n );\n const denormalizedWorkspaceName = extractDenormalizedReferenceDisplay(\n resourceRecord,\n \"workspace\",\n );\n\n const summary = JSON.stringify(extractSummary(resource as FhirResourceLike));\n\n // ADR-018 patterns #2 / #3 / #4 — user- and workspace-partition\n // projection rows. The projection's discriminating fields come from\n // the same FHIR Reference shape the canonical row reads, so the\n // canonical and projection writes always agree on the underlying\n // user / workspace identifiers. Missing identifiers (no user\n // reference at all) skip the user-projection; tenant-scoped\n // Memberships (no workspaceId) skip the workspace-projection — the\n // canonical row still lands either way.\n const userIdFromResource = extractReferenceSlug(resourceRecord, \"user\");\n const workspaceIdFromResource = extractReferenceSlug(\n resourceRecord,\n \"workspace\",\n );\n\n // ADR 2026-03-13-02 § 2 Consequences/Negative — when the resource\n // names a workspace, that workspace must belong to the referenced\n // tenant. Prefer the resource's `tenant.reference` (the issue's\n // \"referenced tenant\" wording) and fall back to the context tenant\n // when the body omits the reference. The check fires before any\n // write so the constraint is enforced atomically with respect to\n // the canonical row.\n if (workspaceIdFromResource !== undefined) {\n const tenantIdFromResource = extractReferenceSlug(resourceRecord, \"tenant\");\n const referencedTenantId = tenantIdFromResource ?? context.tenantId;\n await assertWorkspaceInTenantOperation({\n tenantId: referencedTenantId,\n workspaceId: workspaceIdFromResource,\n tableName,\n });\n }\n\n const userProjectionItem =\n userIdFromResource !== undefined\n ? buildMembershipUserProjectionItem({\n tenantId: context.tenantId,\n userId: userIdFromResource,\n workspaceId: workspaceIdFromResource,\n membershipId: id,\n summary,\n vid,\n lastUpdated,\n denormalizedTenantName,\n denormalizedUserName,\n denormalizedWorkspaceName,\n })\n : undefined;\n\n const workspaceProjectionItem =\n userIdFromResource !== undefined && workspaceIdFromResource !== undefined\n ? buildMembershipWorkspaceProjectionItem({\n tenantId: context.tenantId,\n workspaceId: workspaceIdFromResource,\n userId: userIdFromResource,\n membershipId: id,\n summary,\n vid,\n lastUpdated,\n denormalizedUserName,\n })\n : undefined;\n\n const canonicalItem = {\n tenantId: context.tenantId,\n id,\n resource: JSON.stringify(resource),\n summary,\n vid,\n lastUpdated,\n linkedDataIdentityRef,\n denormalizedTenantName,\n denormalizedUserName,\n };\n\n const triples: Array<MultiWriteTriple> = [\n { entity: \"membership\", action: \"put\", item: canonicalItem },\n ];\n if (userProjectionItem) {\n triples.push({\n entity: \"membershipUserProjection\",\n action: \"put\",\n item: userProjectionItem as unknown as Record<string, unknown>,\n });\n }\n if (workspaceProjectionItem) {\n triples.push({\n entity: \"membershipWorkspaceProjection\",\n action: \"put\",\n item: workspaceProjectionItem as unknown as Record<string, unknown>,\n });\n }\n\n await executeMultiWrite({ service, triples });\n\n return {\n id,\n resource,\n meta: { lastUpdated, versionId: vid },\n };\n}\n","/**\n * Helpers that capture denormalized display-name attributes from an\n * incoming FHIR Reference on Membership and RoleAssignment resources.\n *\n * **Foundational implementation for TR-024.** ADR-018 § Implementation\n * Notes and TR-024 § Recommendation pin the authoritative write-time\n * source as the carrier entity's canonical `displayName` — i.e. the\n * operations layer reads the canonical Tenant / User / Role record\n * by id and copies its display name into the relationship's\n * `denormalized<CarrierEntity>Name` field on the same\n * `TransactWriteItems`. That contract is owned by the operations-layer\n * multi-write helper filed as #1010.\n *\n * This module is the **foundational fallback** that issue #1009 uses\n * before #1010 lands: it reads the display string a client already\n * supplies on the resource's `Reference.display` field. The fallback\n * keeps Membership / RoleAssignment write paths populating the new\n * top-level attributes today (so adjacency-list projection writers\n * downstream can rely on them) while letting #1010 own the canonical-\n * row lookup without re-plumbing the write paths.\n *\n * @see TR-024 — Denormalized display-name attributes\n * @see ADR-018 § Implementation Notes\n */\n\n/**\n * Returns the trimmed display string from `resource[fieldName].display`\n * when present and non-empty; otherwise returns `undefined`. Used by the\n * Membership and RoleAssignment create / update operations to populate\n * top-level `denormalized<CarrierEntity>Name` attributes from incoming\n * FHIR Reference fields.\n *\n * Field name maps to the FHIR field on the resource:\n * - `Membership.tenant` → `\"tenant\"`\n * - `Membership.user` → `\"user\"`\n * - `RoleAssignment.tenant` → `\"tenant\"`\n * - `RoleAssignment.user` → `\"user\"`\n * - `RoleAssignment.role` → `\"role\"`\n *\n * Guards against malformed payloads (non-object `field`, non-string\n * `display`, empty strings after trim) so a single bad write never\n * blocks an entity put — matching the same defensive posture\n * `gsi1skAttribute` takes for the `resource` JSON parse.\n */\nexport function extractDenormalizedReferenceDisplay(\n resource: Record<string, unknown>,\n fieldName: string,\n): string | undefined {\n const field = resource[fieldName];\n if (!field || typeof field !== \"object\") {\n return undefined;\n }\n const display = (field as { display?: unknown }).display;\n if (typeof display !== \"string\") {\n return undefined;\n }\n const trimmed = display.trim();\n return trimmed.length > 0 ? trimmed : undefined;\n}\n","import { getDynamoControlService } from \"../../../dynamo/dynamo-control-service\";\nimport { ConflictError } from \"../../../errors\";\n\n/** Inputs to {@link assertWorkspaceInTenantOperation}. */\nexport interface AssertWorkspaceInTenantParams {\n /** Tenant the workspace must belong to. */\n readonly tenantId: string;\n /** Workspace id that must exist under `tenantId`. */\n readonly workspaceId: string;\n /** Optional table-name override; resolved via env when omitted. */\n readonly tableName?: string;\n}\n\n/**\n * Throws {@link ConflictError} when no `Workspace` with id `workspaceId`\n * exists under `tenantId`.\n *\n * Implementation: a single base-table `get` against the Workspace entity\n * (`PK = TID#<tenantId>#WORKSPACE#ID#<workspaceId>, SK = CURRENT`). The\n * workspace's tenant is encoded into the PK by construction, so a hit\n * *is* the constraint check — there is no separate `managingOrganization`\n * comparison required.\n *\n * @see ADR 2026-03-13-02 § 2 — Consequences/Negative\n */\nexport async function assertWorkspaceInTenantOperation(\n params: AssertWorkspaceInTenantParams,\n): Promise<void> {\n const { tenantId, workspaceId, tableName } = params;\n const service = getDynamoControlService(tableName);\n\n const { data: item } = await service.entities.workspace\n .get({ tenantId, id: workspaceId, sk: \"CURRENT\" })\n .go();\n\n if (!item) {\n throw new ConflictError(\n `Workspace ${workspaceId} does not belong to tenant ${tenantId}; the workspace must be created in the referenced tenant before this resource can reference it.`,\n { details: { tenantId, workspaceId } },\n );\n }\n}\n","import { extractSummary, type FhirResourceLike } from \"@openhi/types\";\nimport {\n buildRoleAssignmentUserProjectionItem,\n extractReferenceSlug,\n} from \"./roleassignment-user-projection\";\nimport { buildRoleAssignmentWorkspaceProjectionItem } from \"./roleassignment-workspace-projection\";\nimport { getDynamoControlService } from \"../../../dynamo/dynamo-control-service\";\nimport { OpenHiContext } from \"../../../openhi-context\";\nimport { extractDenormalizedReferenceDisplay } from \"../denormalized-display-names\";\nimport { assertUserHasTenantMembershipOperation } from \"../membership-constraints/assert-user-has-tenant-membership-operation\";\nimport { assertWorkspaceInTenantOperation } from \"../membership-constraints/assert-workspace-in-tenant-operation\";\nimport {\n executeMultiWrite,\n type MultiWriteTriple,\n} from \"../multi-write-operation\";\n\nexport interface RoleAssignmentCreateParams {\n context: OpenHiContext;\n body: { id?: string; resource?: Record<string, unknown> | string };\n tableName?: string;\n}\n\nexport interface RoleAssignmentCreateResult {\n id: string;\n resource: { resourceType: string; id: string; [key: string]: unknown };\n meta: { lastUpdated: string; versionId: string };\n}\n\nexport async function createRoleAssignmentOperation(\n params: RoleAssignmentCreateParams,\n): Promise<RoleAssignmentCreateResult> {\n const { context, body, tableName } = params;\n const service = getDynamoControlService(tableName);\n\n const id = body.id ?? `roleassignment-${Date.now()}`;\n const parsedResource =\n typeof body.resource === \"string\"\n ? (JSON.parse(body.resource) as Record<string, unknown>)\n : (body.resource ?? {});\n\n const lastUpdated = context.date ?? new Date().toISOString();\n const vid = `1`;\n\n const resource = { resourceType: \"RoleAssignment\", id, ...parsedResource };\n\n // TR-024 denormalized display-name attributes. The authoritative\n // write-time source per TR-024 rule 2 is the canonical Tenant / User /\n // Role record's `displayName`. Until the carrier-record-lookup pass\n // lands (#1010 follow-up), the foundational fallback (#1009) reads\n // the FHIR Reference.display values supplied on the resource so the\n // adjacency-list projection writer here can compose SKs from\n // top-level attributes.\n const resourceRecord = resource as Record<string, unknown>;\n const denormalizedTenantName = extractDenormalizedReferenceDisplay(\n resourceRecord,\n \"tenant\",\n );\n const denormalizedUserName = extractDenormalizedReferenceDisplay(\n resourceRecord,\n \"user\",\n );\n const denormalizedRoleName = extractDenormalizedReferenceDisplay(\n resourceRecord,\n \"role\",\n );\n\n const summary = JSON.stringify(extractSummary(resource as FhirResourceLike));\n\n // ADR-018 patterns #5 / #9 — user- and workspace-partition projection\n // rows. The projection's discriminating fields come from the same\n // FHIR Reference shape the canonical row reads, so the canonical and\n // projection writes always agree on the underlying user / role /\n // workspace identifiers. Missing user or role references skip the\n // user-projection; tenant-scoped RoleAssignments (no workspaceId)\n // skip the workspace-projection — the canonical row still lands\n // either way.\n const userIdFromResource = extractReferenceSlug(resourceRecord, \"user\");\n const roleIdFromResource = extractReferenceSlug(resourceRecord, \"role\");\n const workspaceIdFromResource = extractReferenceSlug(\n resourceRecord,\n \"workspace\",\n );\n\n // ADR 2026-03-13-02 § 2 Consequences/Negative — a RoleAssignment may\n // only be created when the referenced user already holds a\n // tenant-level Membership in the referenced tenant, and (when\n // workspace-scoped) that workspace must belong to the same tenant.\n // Prefer the resource's `tenant.reference` (the issue's \"referenced\n // tenant\" wording) and fall back to the context tenant when the\n // body omits the reference. Constraints fire before any write so\n // the rejection is atomic with respect to the canonical row.\n if (userIdFromResource !== undefined) {\n const tenantIdFromResource = extractReferenceSlug(resourceRecord, \"tenant\");\n const referencedTenantId = tenantIdFromResource ?? context.tenantId;\n await assertUserHasTenantMembershipOperation({\n userId: userIdFromResource,\n tenantId: referencedTenantId,\n tableName,\n });\n if (workspaceIdFromResource !== undefined) {\n await assertWorkspaceInTenantOperation({\n tenantId: referencedTenantId,\n workspaceId: workspaceIdFromResource,\n tableName,\n });\n }\n }\n\n const userProjectionItem =\n userIdFromResource !== undefined && roleIdFromResource !== undefined\n ? buildRoleAssignmentUserProjectionItem({\n tenantId: context.tenantId,\n userId: userIdFromResource,\n workspaceId: workspaceIdFromResource,\n roleId: roleIdFromResource,\n roleAssignmentId: id,\n summary,\n vid,\n lastUpdated,\n denormalizedTenantName,\n denormalizedUserName,\n denormalizedRoleName,\n })\n : undefined;\n\n const workspaceProjectionItem =\n userIdFromResource !== undefined &&\n roleIdFromResource !== undefined &&\n workspaceIdFromResource !== undefined\n ? buildRoleAssignmentWorkspaceProjectionItem({\n tenantId: context.tenantId,\n workspaceId: workspaceIdFromResource,\n userId: userIdFromResource,\n roleId: roleIdFromResource,\n roleAssignmentId: id,\n summary,\n vid,\n lastUpdated,\n denormalizedUserName,\n denormalizedRoleName,\n })\n : undefined;\n\n const canonicalItem = {\n tenantId: context.tenantId,\n id,\n resource: JSON.stringify(resource),\n summary,\n vid,\n lastUpdated,\n denormalizedTenantName,\n denormalizedUserName,\n denormalizedRoleName,\n };\n\n const triples: Array<MultiWriteTriple> = [\n { entity: \"roleAssignment\", action: \"put\", item: canonicalItem },\n ];\n if (userProjectionItem) {\n triples.push({\n entity: \"roleAssignmentUserProjection\",\n action: \"put\",\n item: userProjectionItem as unknown as Record<string, unknown>,\n });\n }\n if (workspaceProjectionItem) {\n triples.push({\n entity: \"roleAssignmentWorkspaceProjection\",\n action: \"put\",\n item: workspaceProjectionItem as unknown as Record<string, unknown>,\n });\n }\n\n await executeMultiWrite({ service, triples });\n\n return {\n id,\n resource,\n meta: { lastUpdated, versionId: vid },\n };\n}\n","import { getDynamoControlService } from \"../../../dynamo/dynamo-control-service\";\nimport { ConflictError } from \"../../../errors\";\n\n/**\n * SK prefix for the tenant-level Membership sub-lane (ADR-018 pattern #3).\n *\n * Tenant-level Memberships project under the user partition with\n * `SK = MEMBERSHIP#TENANT#<normalizedTenantName>#TID#<tenantId>#<membershipId>`.\n * Because the `<tenantId>` segment appears *after* `<normalizedTenantName>`,\n * a `begins_with` filter cannot narrow to a single tenant — we read every\n * tenant-lane row for the user and filter on the projection-row's\n * `tenantId` attribute in memory.\n */\nconst TENANT_LANE_SK_PREFIX = \"MEMBERSHIP#TENANT#\";\n\n/** Inputs to {@link assertUserHasTenantMembershipOperation}. */\nexport interface AssertUserHasTenantMembershipParams {\n /** The user that must already be a member of `tenantId`. */\n readonly userId: string;\n /** The tenant the user must hold a tenant-level Membership in. */\n readonly tenantId: string;\n /** Optional table-name override; resolved via env when omitted. */\n readonly tableName?: string;\n}\n\n/**\n * Throws {@link ConflictError} when `userId` has no tenant-level\n * `Membership` in `tenantId`.\n *\n * Implementation: queries the ADR-018 user-partition projection\n * (`MembershipUserProjectionEntity`) under\n * `PK = USER#ID#<userId>, SK begins_with 'MEMBERSHIP#TENANT#'` — a single\n * strongly-consistent base-table query with no GSI hop — and filters the\n * returned rows for `tenantId` in memory.\n *\n * The filter cannot be expressed as an additional `begins_with` because\n * the projection SK encodes `<normalizedTenantName>` *before* `<tenantId>`\n * (`MEMBERSHIP#TENANT#<normalizedTenantName>#TID#<tenantId>#<id>`). The\n * scan cost is bounded by the user's tenant-membership fan-out, which is\n * small by construction.\n *\n * @see ADR 2026-03-13-02 § 2 — Consequences/Negative\n * @see ADR-018 § Access Pattern Coverage (pattern #3)\n */\nexport async function assertUserHasTenantMembershipOperation(\n params: AssertUserHasTenantMembershipParams,\n): Promise<void> {\n const { userId, tenantId, tableName } = params;\n const service = getDynamoControlService(tableName);\n\n const result = await service.entities.membershipUserProjection.query\n .record({ userId })\n .begins({ sk: TENANT_LANE_SK_PREFIX })\n .go();\n\n const matched = (result.data ?? []).some((row) => row.tenantId === tenantId);\n if (!matched) {\n throw new ConflictError(\n `User ${userId} has no tenant-level Membership in tenant ${tenantId}; a Membership must exist before a RoleAssignment can be created.`,\n { details: { userId, tenantId } },\n );\n }\n}\n","import { extractSummary, type FhirResourceLike } from \"@openhi/types\";\nimport { getDynamoControlService } from \"../../../dynamo/dynamo-control-service\";\nimport type { OpenHiContext } from \"../../../openhi-context\";\n\nexport interface CreateTenantParams {\n context: OpenHiContext;\n body: {\n id?: string;\n resource?: Record<string, unknown> | string;\n };\n tableName?: string;\n}\n\nexport interface CreateTenantResult {\n id: string;\n resource: Record<string, unknown>;\n meta: { lastUpdated: string; versionId: string };\n}\n\n/**\n * Creates a Tenant. Generates an id if not provided.\n */\nexport async function createTenantOperation(\n params: CreateTenantParams,\n): Promise<CreateTenantResult> {\n const { context, body, tableName } = params;\n const service = getDynamoControlService(tableName);\n\n const id = body.id ?? `tenant-${Date.now()}`;\n const lastUpdated = context.date;\n const vid =\n lastUpdated.replace(/[-:T.Z]/g, \"\").slice(0, 12) || Date.now().toString(36);\n\n const parsedResource =\n typeof body.resource === \"string\"\n ? (JSON.parse(body.resource) as Record<string, unknown>)\n : (body.resource ?? {});\n\n const resource = { resourceType: \"Tenant\", id, ...parsedResource };\n const summary = JSON.stringify(extractSummary(resource as FhirResourceLike));\n\n await service.entities.tenant\n .put({\n tenantId: id,\n id,\n resource: JSON.stringify(resource),\n summary,\n vid,\n lastUpdated,\n })\n .go();\n\n return { id, resource, meta: { lastUpdated, versionId: vid } };\n}\n","import { extractSummary, type FhirResourceLike } from \"@openhi/types\";\nimport { getDynamoControlService } from \"../../../dynamo/dynamo-control-service\";\nimport type { OpenHiContext } from \"../../../openhi-context\";\nimport { provisionOrganizationForWorkspaceOperation } from \"../../data/organization/organization-provision-for-workspace-operation\";\n\nexport interface CreateWorkspaceParams {\n context: OpenHiContext;\n body: {\n id?: string;\n resource?: Record<string, unknown> | string;\n };\n tableName?: string;\n}\n\nexport interface CreateWorkspaceResult {\n id: string;\n resource: Record<string, unknown>;\n meta: { lastUpdated: string; versionId: string };\n}\n\n/**\n * Creates a Workspace scoped to the context tenant. Generates an id if not provided.\n */\nexport async function createWorkspaceOperation(\n params: CreateWorkspaceParams,\n): Promise<CreateWorkspaceResult> {\n const { context, body, tableName } = params;\n const { tenantId } = context;\n const service = getDynamoControlService(tableName);\n\n const id = body.id ?? `workspace-${Date.now()}`;\n const lastUpdated = context.date;\n const vid =\n lastUpdated.replace(/[-:T.Z]/g, \"\").slice(0, 12) || Date.now().toString(36);\n\n const parsedResource =\n typeof body.resource === \"string\"\n ? (JSON.parse(body.resource) as Record<string, unknown>)\n : (body.resource ?? {});\n\n const resource = { resourceType: \"Workspace\", id, ...parsedResource };\n const summary = JSON.stringify(extractSummary(resource as FhirResourceLike));\n\n await service.entities.workspace\n .put({\n tenantId,\n id,\n resource: JSON.stringify(resource),\n summary,\n vid,\n lastUpdated,\n })\n .go();\n\n const workspaceName =\n typeof parsedResource.name === \"string\" ? parsedResource.name : undefined;\n await provisionOrganizationForWorkspaceOperation({\n context,\n workspaceId: id,\n workspaceName,\n tableName,\n });\n\n return { id, resource, meta: { lastUpdated, versionId: vid } };\n}\n","import type { Organization, Reference } from \"@openhi/types\";\nimport { getDynamoDataService } from \"../../../dynamo/dynamo-data-service\";\nimport type { OpenHiContext } from \"../../../openhi-context\";\nimport {\n createDataEntityRecord,\n DATA_ENTITY_SK,\n type SingleResourceResult,\n} from \"../../data-operations-common\";\n\n/**\n * Provision the slim, id-share Organization that represents a Workspace on the data plane.\n *\n * - `Organization.id === workspaceId` (id-share with the Workspace).\n * - `name` is populated from the Workspace's name when provided.\n * - `partOf` is populated when the Workspace's Tenant has an Organization (id-share at the\n * tenant level: `Tenant.id === TenantOrganization.id`). Tenant-level provisioning is out of\n * scope for #1001 — the lookup returns `undefined` until Tenant Organizations exist.\n *\n * Idempotent: re-running with the same params overwrites the same PK with the same payload.\n *\n * @see https://github.com/codedrifters/openhi/issues/1001\n */\nexport interface ProvisionOrganizationForWorkspaceParams {\n context: OpenHiContext;\n /** Workspace id; the provisioned Organization's id equals this value. */\n workspaceId: string;\n /** Name to record on the Organization. Derived from the Workspace's `name` by the caller. */\n workspaceName?: string;\n /** Optional table name override; resolved by data service from DYNAMO_TABLE_NAME when omitted. */\n tableName?: string;\n}\n\nexport type ProvisionOrganizationForWorkspaceResult =\n SingleResourceResult<Organization>;\n\nexport async function provisionOrganizationForWorkspaceOperation(\n params: ProvisionOrganizationForWorkspaceParams,\n): Promise<ProvisionOrganizationForWorkspaceResult> {\n const { context, workspaceId, workspaceName, tableName } = params;\n const { tenantId, date } = context;\n const service = getDynamoDataService(tableName);\n\n const partOf = await resolveTenantOrganizationReference(service, tenantId);\n\n const resource: Organization = {\n resourceType: \"Organization\",\n id: workspaceId,\n ...(workspaceName !== undefined && workspaceName !== \"\"\n ? { name: workspaceName }\n : {}),\n ...(partOf !== undefined ? { partOf } : {}),\n meta: {\n lastUpdated: date,\n versionId: \"1\",\n },\n };\n\n return createDataEntityRecord<Organization>(\n service.entities.organization as Parameters<\n typeof createDataEntityRecord\n >[0],\n tenantId,\n workspaceId,\n workspaceId,\n resource,\n date,\n );\n}\n\n/**\n * Resolve the Tenant's Organization reference for `Organization.partOf`. Returns `undefined`\n * when the Tenant has no Organization yet (the default until Tenant Organization provisioning\n * is wired up in a follow-up).\n *\n * Lookup convention: the Tenant's Organization is stored at\n * `(tenantId, workspaceId=tenantId, id=tenantId)` — a self-scope key that mirrors the\n * Workspace id-share pattern one level up. This scoping convention will be ratified by the\n * Tenant Organization provisioning issue; the OrganizationEntity comment notes strict\n * isolation requires both `tenantId` and `workspaceId`, so the self-scope is the\n * simplest deterministic location.\n */\nasync function resolveTenantOrganizationReference(\n service: ReturnType<typeof getDynamoDataService>,\n tenantId: string,\n): Promise<Reference | undefined> {\n const result = await (\n service.entities.organization as {\n get(params: {\n tenantId: string;\n workspaceId: string;\n id: string;\n sk: string;\n }): { go(): Promise<{ data: unknown }> };\n }\n )\n .get({\n tenantId,\n workspaceId: tenantId,\n id: tenantId,\n sk: DATA_ENTITY_SK,\n })\n .go();\n\n if (result.data === null || result.data === undefined) {\n return undefined;\n }\n\n return { reference: `Organization/${tenantId}` };\n}\n"],"mappings":"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","names":["extractSummary","extractSummary","extractReferenceSlug","extractSummary","extractSummary","extractSummary","extractSummary"]}
@@ -14,7 +14,7 @@ import {
14
14
  createRoleAssignmentOperation,
15
15
  createTenantOperation,
16
16
  createWorkspaceOperation
17
- } from "./chunk-QWWLM452.mjs";
17
+ } from "./chunk-7WDX6GPO.mjs";
18
18
  import {
19
19
  NotFoundError
20
20
  } from "./chunk-FYHBHHWK.mjs";
@@ -932,4 +932,4 @@ export {
932
932
  productionCognitoProvisioner,
933
933
  handler
934
934
  };
935
- //# sourceMappingURL=chunk-AJQUWHFK.mjs.map
935
+ //# sourceMappingURL=chunk-WXS3PUHR.mjs.map
package/lib/index.d.mts CHANGED
@@ -343,6 +343,15 @@ declare abstract class OpenHiService extends Stack {
343
343
  * Short hash unique to the environment and branch combination.
344
344
  */
345
345
  readonly branchHash: string;
346
+ /**
347
+ * Branch hash computed against {@link defaultReleaseBranch} rather than
348
+ * {@link branchName}. On the release branch this equals {@link branchHash};
349
+ * on every other branch it identifies the namespace the release-branch
350
+ * deploy of this service writes to. Use when looking up SSM parameters
351
+ * that only the release-branch stack publishes (e.g. shared static-hosting
352
+ * bucket ARN).
353
+ */
354
+ readonly releaseBranchHash: string;
346
355
  /**
347
356
  * Short hash unique to the specific stack/service.
348
357
  */
@@ -1007,6 +1016,12 @@ declare class DiscoverableStringParameter extends StringParameter {
1007
1016
  * Props for the StaticContent construct.
1008
1017
  */
1009
1018
  interface StaticContentProps {
1019
+ /**
1020
+ * Destination bucket the content is uploaded to. Callers resolve this
1021
+ * reference themselves so the construct doesn't need to know whether the
1022
+ * bucket was created in the same stack or imported across branches.
1023
+ */
1024
+ readonly bucket: IBucket;
1010
1025
  /**
1011
1026
  * Absolute path to directory containing content for the website.
1012
1027
  */
@@ -1031,21 +1046,14 @@ interface StaticContentProps {
1031
1046
  * `<sub-domain>.<full-domain>`.
1032
1047
  */
1033
1048
  readonly fullDomain: string;
1034
- /**
1035
- * Service type used to look up the static-hosting bucket ARN via
1036
- * DiscoverableStringParameter.
1037
- *
1038
- * @default STATIC_HOSTING_SERVICE_TYPE ("website")
1039
- */
1040
- readonly serviceType?: string;
1041
1049
  }
1042
1050
  /**
1043
- * Static content uploader: deploys a local directory to the static-hosting
1051
+ * Static content uploader: deploys a local directory to a static-hosting
1044
1052
  * S3 bucket under `<sub-domain>.<full-domain>/<dest>` so each branch
1045
- * deploys to its own prefix without clobbering siblings. The bucket ARN is
1046
- * looked up via DiscoverableStringParameter so the uploader can run on a
1047
- * feature-branch stack while the bucket itself was provisioned by the
1048
- * release-branch service stack.
1053
+ * deploys to its own prefix without clobbering siblings. The destination
1054
+ * bucket is supplied by the caller, which lets the same construct run in
1055
+ * both same-stack (release-branch) and cross-stack/cross-branch
1056
+ * (feature-branch) contexts.
1049
1057
  */
1050
1058
  declare class StaticContent extends Construct {
1051
1059
  constructor(scope: Construct, id: string, props: StaticContentProps);
@@ -1956,6 +1964,17 @@ interface OpenHiWebsiteServiceProps extends OpenHiServiceProps {
1956
1964
  * @default - true on release branch, false otherwise
1957
1965
  */
1958
1966
  readonly createHostingInfrastructure?: boolean;
1967
+ /**
1968
+ * Whether to create the `StaticContent` uploader. Set to `false` to skip
1969
+ * it entirely on every branch — used as a one-shot bootstrap toggle while
1970
+ * the release-branch deploy of this service first creates the static-hosting
1971
+ * bucket and writes `STATIC_HOSTING_BUCKET_ARN` to SSM. Once that
1972
+ * parameter exists, flip back to `true` so feature-branch deploys can read
1973
+ * it and upload content under their per-branch sub-domain folder.
1974
+ *
1975
+ * @default true
1976
+ */
1977
+ readonly createStaticContent?: boolean;
1959
1978
  }
1960
1979
  /**
1961
1980
  * SSM parameter name suffix for the website's full domain
@@ -2012,16 +2031,23 @@ declare class OpenHiWebsiteService extends OpenHiService {
2012
2031
  */
2013
2032
  readonly staticHosting?: StaticHosting;
2014
2033
  /**
2015
- * The content uploader, always created.
2034
+ * The content uploader. Created on every deploy unless
2035
+ * {@link OpenHiWebsiteServiceProps.createStaticContent} is `false`, in
2036
+ * which case the property is `undefined` and the stack ships no
2037
+ * `BucketDeployment`. Used during release-branch bootstrap, before the
2038
+ * shared static-hosting bucket has been written to SSM for the first time.
2016
2039
  */
2017
- readonly staticContent: StaticContent;
2040
+ readonly staticContent?: StaticContent;
2018
2041
  constructor(ohEnv: OpenHiEnvironment, props: OpenHiWebsiteServiceProps);
2019
2042
  /**
2020
2043
  * Validates that config required for the website stack is present.
2021
2044
  */
2022
2045
  protected validateConfig(props: OpenHiWebsiteServiceProps): void;
2023
2046
  /**
2024
- * Looks up the child hosted zone published by the Global service.
2047
+ * Imports the website's hosted zone from config attributes (no SSM lookup).
2048
+ * The website attaches DNS records here on the release-branch deploy and
2049
+ * the same zone is imported on feature-branch deploys for any sub-domain
2050
+ * routing.
2025
2051
  * Override to customize.
2026
2052
  */
2027
2053
  protected createHostedZone(): IHostedZone;
@@ -2052,8 +2078,23 @@ declare class OpenHiWebsiteService extends OpenHiService {
2052
2078
  * Creates the StaticContent uploader. Always created so feature-branch
2053
2079
  * deploys can publish content to their own sub-domain folder against the
2054
2080
  * release-branch bucket.
2081
+ *
2082
+ * The destination bucket is resolved here so the construct never has to
2083
+ * branch on release-vs-feature: on the release branch we pass the
2084
+ * just-created {@link staticHosting} bucket directly (no SSM round-trip
2085
+ * within a single stack); on every other branch we look up the bucket
2086
+ * ARN published by the release-branch deploy, addressed against
2087
+ * {@link OpenHiService.releaseBranchHash}.
2055
2088
  */
2056
2089
  protected createStaticContent(): StaticContent;
2090
+ /**
2091
+ * Returns an {@link IBucket} pointing at the static-hosting bucket the
2092
+ * uploader writes to. On the release-branch deploy this is the bucket
2093
+ * just provisioned by {@link staticHosting}; on every other deploy it's
2094
+ * imported from the bucket ARN the release-branch deploy publishes to
2095
+ * SSM, addressed against {@link OpenHiService.releaseBranchHash}.
2096
+ */
2097
+ protected resolveStaticHostingBucket(): IBucket;
2057
2098
  }
2058
2099
 
2059
2100
  interface OwningDeleteCascadeLambdasProps {
package/lib/index.d.ts CHANGED
@@ -980,6 +980,15 @@ declare abstract class OpenHiService extends Stack {
980
980
  * Short hash unique to the environment and branch combination.
981
981
  */
982
982
  readonly branchHash: string;
983
+ /**
984
+ * Branch hash computed against {@link defaultReleaseBranch} rather than
985
+ * {@link branchName}. On the release branch this equals {@link branchHash};
986
+ * on every other branch it identifies the namespace the release-branch
987
+ * deploy of this service writes to. Use when looking up SSM parameters
988
+ * that only the release-branch stack publishes (e.g. shared static-hosting
989
+ * bucket ARN).
990
+ */
991
+ readonly releaseBranchHash: string;
983
992
  /**
984
993
  * Short hash unique to the specific stack/service.
985
994
  */
@@ -1644,6 +1653,12 @@ declare class DiscoverableStringParameter extends StringParameter {
1644
1653
  * Props for the StaticContent construct.
1645
1654
  */
1646
1655
  interface StaticContentProps {
1656
+ /**
1657
+ * Destination bucket the content is uploaded to. Callers resolve this
1658
+ * reference themselves so the construct doesn't need to know whether the
1659
+ * bucket was created in the same stack or imported across branches.
1660
+ */
1661
+ readonly bucket: IBucket;
1647
1662
  /**
1648
1663
  * Absolute path to directory containing content for the website.
1649
1664
  */
@@ -1668,21 +1683,14 @@ interface StaticContentProps {
1668
1683
  * `<sub-domain>.<full-domain>`.
1669
1684
  */
1670
1685
  readonly fullDomain: string;
1671
- /**
1672
- * Service type used to look up the static-hosting bucket ARN via
1673
- * DiscoverableStringParameter.
1674
- *
1675
- * @default STATIC_HOSTING_SERVICE_TYPE ("website")
1676
- */
1677
- readonly serviceType?: string;
1678
1686
  }
1679
1687
  /**
1680
- * Static content uploader: deploys a local directory to the static-hosting
1688
+ * Static content uploader: deploys a local directory to a static-hosting
1681
1689
  * S3 bucket under `<sub-domain>.<full-domain>/<dest>` so each branch
1682
- * deploys to its own prefix without clobbering siblings. The bucket ARN is
1683
- * looked up via DiscoverableStringParameter so the uploader can run on a
1684
- * feature-branch stack while the bucket itself was provisioned by the
1685
- * release-branch service stack.
1690
+ * deploys to its own prefix without clobbering siblings. The destination
1691
+ * bucket is supplied by the caller, which lets the same construct run in
1692
+ * both same-stack (release-branch) and cross-stack/cross-branch
1693
+ * (feature-branch) contexts.
1686
1694
  */
1687
1695
  declare class StaticContent extends Construct {
1688
1696
  constructor(scope: Construct, id: string, props: StaticContentProps);
@@ -2593,6 +2601,17 @@ interface OpenHiWebsiteServiceProps extends OpenHiServiceProps {
2593
2601
  * @default - true on release branch, false otherwise
2594
2602
  */
2595
2603
  readonly createHostingInfrastructure?: boolean;
2604
+ /**
2605
+ * Whether to create the `StaticContent` uploader. Set to `false` to skip
2606
+ * it entirely on every branch — used as a one-shot bootstrap toggle while
2607
+ * the release-branch deploy of this service first creates the static-hosting
2608
+ * bucket and writes `STATIC_HOSTING_BUCKET_ARN` to SSM. Once that
2609
+ * parameter exists, flip back to `true` so feature-branch deploys can read
2610
+ * it and upload content under their per-branch sub-domain folder.
2611
+ *
2612
+ * @default true
2613
+ */
2614
+ readonly createStaticContent?: boolean;
2596
2615
  }
2597
2616
  /**
2598
2617
  * SSM parameter name suffix for the website's full domain
@@ -2649,16 +2668,23 @@ declare class OpenHiWebsiteService extends OpenHiService {
2649
2668
  */
2650
2669
  readonly staticHosting?: StaticHosting;
2651
2670
  /**
2652
- * The content uploader, always created.
2671
+ * The content uploader. Created on every deploy unless
2672
+ * {@link OpenHiWebsiteServiceProps.createStaticContent} is `false`, in
2673
+ * which case the property is `undefined` and the stack ships no
2674
+ * `BucketDeployment`. Used during release-branch bootstrap, before the
2675
+ * shared static-hosting bucket has been written to SSM for the first time.
2653
2676
  */
2654
- readonly staticContent: StaticContent;
2677
+ readonly staticContent?: StaticContent;
2655
2678
  constructor(ohEnv: OpenHiEnvironment, props: OpenHiWebsiteServiceProps);
2656
2679
  /**
2657
2680
  * Validates that config required for the website stack is present.
2658
2681
  */
2659
2682
  protected validateConfig(props: OpenHiWebsiteServiceProps): void;
2660
2683
  /**
2661
- * Looks up the child hosted zone published by the Global service.
2684
+ * Imports the website's hosted zone from config attributes (no SSM lookup).
2685
+ * The website attaches DNS records here on the release-branch deploy and
2686
+ * the same zone is imported on feature-branch deploys for any sub-domain
2687
+ * routing.
2662
2688
  * Override to customize.
2663
2689
  */
2664
2690
  protected createHostedZone(): IHostedZone;
@@ -2689,8 +2715,23 @@ declare class OpenHiWebsiteService extends OpenHiService {
2689
2715
  * Creates the StaticContent uploader. Always created so feature-branch
2690
2716
  * deploys can publish content to their own sub-domain folder against the
2691
2717
  * release-branch bucket.
2718
+ *
2719
+ * The destination bucket is resolved here so the construct never has to
2720
+ * branch on release-vs-feature: on the release branch we pass the
2721
+ * just-created {@link staticHosting} bucket directly (no SSM round-trip
2722
+ * within a single stack); on every other branch we look up the bucket
2723
+ * ARN published by the release-branch deploy, addressed against
2724
+ * {@link OpenHiService.releaseBranchHash}.
2692
2725
  */
2693
2726
  protected createStaticContent(): StaticContent;
2727
+ /**
2728
+ * Returns an {@link IBucket} pointing at the static-hosting bucket the
2729
+ * uploader writes to. On the release-branch deploy this is the bucket
2730
+ * just provisioned by {@link staticHosting}; on every other deploy it's
2731
+ * imported from the bucket ARN the release-branch deploy publishes to
2732
+ * SSM, addressed against {@link OpenHiService.releaseBranchHash}.
2733
+ */
2734
+ protected resolveStaticHostingBucket(): IBucket;
2694
2735
  }
2695
2736
 
2696
2737
  interface OwningDeleteCascadeLambdasProps {
package/lib/index.js CHANGED
@@ -1127,6 +1127,16 @@ var OpenHiService = class extends import_aws_cdk_lib4.Stack {
1127
1127
  ),
1128
1128
  6
1129
1129
  );
1130
+ const releaseBranchHash = (0, import_utils.hashString)(
1131
+ [
1132
+ appName,
1133
+ ohEnv.deploymentTargetRole,
1134
+ account,
1135
+ region,
1136
+ defaultReleaseBranch
1137
+ ].join("-"),
1138
+ 6
1139
+ );
1130
1140
  const stackHash = (0, import_utils.hashString)(
1131
1141
  [
1132
1142
  appName,
@@ -1157,6 +1167,7 @@ var OpenHiService = class extends import_aws_cdk_lib4.Stack {
1157
1167
  this.branchName = branchName;
1158
1168
  this.environmentHash = environmentHash;
1159
1169
  this.branchHash = branchHash;
1170
+ this.releaseBranchHash = releaseBranchHash;
1160
1171
  this.stackHash = stackHash;
1161
1172
  this.node.setContext(
1162
1173
  `availability-zones:account=${account}:region=${region}`,
@@ -2286,10 +2297,31 @@ var RootHostedZone = class extends import_constructs7.Construct {
2286
2297
  };
2287
2298
 
2288
2299
  // src/components/static-hosting/static-content.ts
2289
- var import_aws_s32 = require("aws-cdk-lib/aws-s3");
2290
2300
  var import_aws_s3_deployment = require("aws-cdk-lib/aws-s3-deployment");
2291
2301
  var import_change_case2 = require("change-case");
2292
- var import_constructs9 = require("constructs");
2302
+ var import_constructs8 = require("constructs");
2303
+ var StaticContent = class extends import_constructs8.Construct {
2304
+ constructor(scope, id, props) {
2305
+ super(scope, id);
2306
+ const stack = OpenHiService.of(scope);
2307
+ const {
2308
+ bucket,
2309
+ contentSourceDirectory,
2310
+ contentDestinationDirectory = "/",
2311
+ subDomain = stack.branchName,
2312
+ fullDomain
2313
+ } = props;
2314
+ const keyPrefix = [(0, import_change_case2.paramCase)(subDomain), fullDomain].join(".");
2315
+ const isTestEnv = process.env.JEST_WORKER_ID !== void 0;
2316
+ const sources = isTestEnv ? [] : [import_aws_s3_deployment.Source.asset(contentSourceDirectory)];
2317
+ new import_aws_s3_deployment.BucketDeployment(this, "deploy", {
2318
+ sources,
2319
+ destinationBucket: bucket,
2320
+ retainOnDelete: false,
2321
+ destinationKeyPrefix: `${keyPrefix}${contentDestinationDirectory}`
2322
+ });
2323
+ }
2324
+ };
2293
2325
 
2294
2326
  // src/components/static-hosting/static-hosting.ts
2295
2327
  var fs6 = __toESM(require("fs"));
@@ -2303,9 +2335,9 @@ var import_aws_logs = require("aws-cdk-lib/aws-logs");
2303
2335
  var import_aws_route532 = require("aws-cdk-lib/aws-route53");
2304
2336
  var import_aws_route53_targets = require("aws-cdk-lib/aws-route53-targets");
2305
2337
  var import_aws_s3 = require("aws-cdk-lib/aws-s3");
2306
- var import_constructs8 = require("constructs");
2338
+ var import_constructs9 = require("constructs");
2307
2339
  var STATIC_HOSTING_SERVICE_TYPE = "website";
2308
- var _StaticHosting = class _StaticHosting extends import_constructs8.Construct {
2340
+ var _StaticHosting = class _StaticHosting extends import_constructs9.Construct {
2309
2341
  constructor(scope, id, props = {}) {
2310
2342
  super(scope, id);
2311
2343
  const stack = OpenHiService.of(scope);
@@ -2441,35 +2473,6 @@ _StaticHosting.SSM_PARAM_NAME_DISTRIBUTION_DOMAIN = "STATIC_HOSTING_DISTRIBUTION
2441
2473
  _StaticHosting.SSM_PARAM_NAME_DISTRIBUTION_ID = "STATIC_HOSTING_DISTRIBUTION_ID";
2442
2474
  var StaticHosting = _StaticHosting;
2443
2475
 
2444
- // src/components/static-hosting/static-content.ts
2445
- var StaticContent = class extends import_constructs9.Construct {
2446
- constructor(scope, id, props) {
2447
- super(scope, id);
2448
- const stack = OpenHiService.of(scope);
2449
- const {
2450
- contentSourceDirectory,
2451
- contentDestinationDirectory = "/",
2452
- subDomain = stack.branchName,
2453
- fullDomain,
2454
- serviceType = STATIC_HOSTING_SERVICE_TYPE
2455
- } = props;
2456
- const keyPrefix = [(0, import_change_case2.paramCase)(subDomain), fullDomain].join(".");
2457
- const bucketArn = DiscoverableStringParameter.valueForLookupName(this, {
2458
- ssmParamName: StaticHosting.SSM_PARAM_NAME_BUCKET_ARN,
2459
- serviceType
2460
- });
2461
- const bucket = import_aws_s32.Bucket.fromBucketArn(this, "bucket", bucketArn);
2462
- const isTestEnv = process.env.JEST_WORKER_ID !== void 0;
2463
- const sources = isTestEnv ? [] : [import_aws_s3_deployment.Source.asset(contentSourceDirectory)];
2464
- new import_aws_s3_deployment.BucketDeployment(this, "deploy", {
2465
- sources,
2466
- destinationBucket: bucket,
2467
- retainOnDelete: false,
2468
- destinationKeyPrefix: `${keyPrefix}${contentDestinationDirectory}`
2469
- });
2470
- }
2471
- };
2472
-
2473
2476
  // src/services/open-hi-auth-service.ts
2474
2477
  var import_aws_cognito4 = require("aws-cdk-lib/aws-cognito");
2475
2478
  var import_aws_iam6 = require("aws-cdk-lib/aws-iam");
@@ -7180,6 +7183,7 @@ _OpenHiGraphqlService.SERVICE_TYPE = "graphql-api";
7180
7183
  var OpenHiGraphqlService = _OpenHiGraphqlService;
7181
7184
 
7182
7185
  // src/services/open-hi-website-service.ts
7186
+ var import_aws_s32 = require("aws-cdk-lib/aws-s3");
7183
7187
  var SSM_PARAM_NAME_FULL_DOMAIN = "WEBSITE_FULL_DOMAIN";
7184
7188
  var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
7185
7189
  /**
@@ -7250,7 +7254,9 @@ var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
7250
7254
  });
7251
7255
  this.createFullDomainParameter();
7252
7256
  }
7253
- this.staticContent = this.createStaticContent();
7257
+ if (props.createStaticContent !== false) {
7258
+ this.staticContent = this.createStaticContent();
7259
+ }
7254
7260
  }
7255
7261
  /**
7256
7262
  * Validates that config required for the website stack is present.
@@ -7263,14 +7269,21 @@ var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
7263
7269
  if (!config.zoneName) {
7264
7270
  throw new Error("Zone name is required");
7265
7271
  }
7272
+ if (!config.hostedZoneId) {
7273
+ throw new Error("Hosted zone ID is required to import the website zone");
7274
+ }
7266
7275
  }
7267
7276
  /**
7268
- * Looks up the child hosted zone published by the Global service.
7277
+ * Imports the website's hosted zone from config attributes (no SSM lookup).
7278
+ * The website attaches DNS records here on the release-branch deploy and
7279
+ * the same zone is imported on feature-branch deploys for any sub-domain
7280
+ * routing.
7269
7281
  * Override to customize.
7270
7282
  */
7271
7283
  createHostedZone() {
7272
- return OpenHiGlobalService.childHostedZoneFromConstruct(this, {
7273
- zoneName: this.config.zoneName
7284
+ return OpenHiGlobalService.rootHostedZoneFromConstruct(this, {
7285
+ zoneName: this.config.zoneName,
7286
+ hostedZoneId: this.config.hostedZoneId
7274
7287
  });
7275
7288
  }
7276
7289
  /**
@@ -7317,15 +7330,40 @@ var _OpenHiWebsiteService = class _OpenHiWebsiteService extends OpenHiService {
7317
7330
  * Creates the StaticContent uploader. Always created so feature-branch
7318
7331
  * deploys can publish content to their own sub-domain folder against the
7319
7332
  * release-branch bucket.
7333
+ *
7334
+ * The destination bucket is resolved here so the construct never has to
7335
+ * branch on release-vs-feature: on the release branch we pass the
7336
+ * just-created {@link staticHosting} bucket directly (no SSM round-trip
7337
+ * within a single stack); on every other branch we look up the bucket
7338
+ * ARN published by the release-branch deploy, addressed against
7339
+ * {@link OpenHiService.releaseBranchHash}.
7320
7340
  */
7321
7341
  createStaticContent() {
7322
7342
  const { contentSourceDirectory, contentDestinationDirectory } = this.props;
7323
7343
  return new StaticContent(this, "static-content", {
7344
+ bucket: this.resolveStaticHostingBucket(),
7324
7345
  contentSourceDirectory,
7325
7346
  contentDestinationDirectory,
7326
- fullDomain: this.fullDomain,
7327
- serviceType: _OpenHiWebsiteService.SERVICE_TYPE
7347
+ fullDomain: this.fullDomain
7348
+ });
7349
+ }
7350
+ /**
7351
+ * Returns an {@link IBucket} pointing at the static-hosting bucket the
7352
+ * uploader writes to. On the release-branch deploy this is the bucket
7353
+ * just provisioned by {@link staticHosting}; on every other deploy it's
7354
+ * imported from the bucket ARN the release-branch deploy publishes to
7355
+ * SSM, addressed against {@link OpenHiService.releaseBranchHash}.
7356
+ */
7357
+ resolveStaticHostingBucket() {
7358
+ if (this.staticHosting) {
7359
+ return this.staticHosting.bucket;
7360
+ }
7361
+ const bucketArn = DiscoverableStringParameter.valueForLookupName(this, {
7362
+ ssmParamName: StaticHosting.SSM_PARAM_NAME_BUCKET_ARN,
7363
+ serviceType: _OpenHiWebsiteService.SERVICE_TYPE,
7364
+ branchHash: this.releaseBranchHash
7328
7365
  });
7366
+ return import_aws_s32.Bucket.fromBucketArn(this, "shared-bucket", bucketArn);
7329
7367
  }
7330
7368
  };
7331
7369
  _OpenHiWebsiteService.SERVICE_TYPE = "website";