@open-mercato/core 0.9.1-develop.7518.1.343d9198f1 → 0.9.1-develop.7532.1.8eac7e198f
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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/api/logout.js +6 -1
- package/dist/modules/auth/api/logout.js.map +2 -2
- package/dist/modules/auth/api/session/refresh.js +6 -1
- package/dist/modules/auth/api/session/refresh.js.map +2 -2
- package/dist/modules/catalog/ai-tools/mutation-pack.js +10 -5
- package/dist/modules/catalog/ai-tools/mutation-pack.js.map +2 -2
- package/dist/modules/catalog/ai-tools/types.js.map +2 -2
- package/dist/modules/customer_accounts/api/login.js +14 -7
- package/dist/modules/customer_accounts/api/login.js.map +2 -2
- package/dist/modules/customer_accounts/services/customerUserService.js +10 -10
- package/dist/modules/customer_accounts/services/customerUserService.js.map +2 -2
- package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js +1 -1
- package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js.map +1 -1
- package/dist/modules/forms/api/form-submissions/[id]/submit/route.js +12 -2
- package/dist/modules/forms/api/form-submissions/[id]/submit/route.js.map +2 -2
- package/dist/modules/forms/api/form-submissions/route.js +16 -1
- package/dist/modules/forms/api/form-submissions/route.js.map +2 -2
- package/dist/modules/forms/api/public/submissions/[id]/submit/route.js +6 -1
- package/dist/modules/forms/api/public/submissions/[id]/submit/route.js.map +2 -2
- package/dist/modules/forms/api/runtime-helpers.js +16 -0
- package/dist/modules/forms/api/runtime-helpers.js.map +2 -2
- package/dist/modules/forms/data/validators.js.map +2 -2
- package/dist/modules/forms/lib/portal-subject-policy.js +17 -0
- package/dist/modules/forms/lib/portal-subject-policy.js.map +7 -0
- package/dist/modules/forms/lib/runtime-principal.js +27 -10
- package/dist/modules/forms/lib/runtime-principal.js.map +2 -2
- package/dist/modules/forms/services/distribution-service.js +35 -13
- package/dist/modules/forms/services/distribution-service.js.map +2 -2
- package/dist/modules/forms/services/submission-service.js +4 -0
- package/dist/modules/forms/services/submission-service.js.map +2 -2
- package/dist/modules/staff/commands/leave-requests.js +54 -24
- package/dist/modules/staff/commands/leave-requests.js.map +2 -2
- package/dist/modules/staff/commands/timesheets-entries.js +4 -0
- package/dist/modules/staff/commands/timesheets-entries.js.map +2 -2
- package/dist/modules/staff/commands/timesheets-tags.js +27 -8
- package/dist/modules/staff/commands/timesheets-tags.js.map +2 -2
- package/dist/modules/warranty_claims/api/portal/attachments/route.js +19 -7
- package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
- package/dist/modules/warranty_claims/commands/claims.js +1 -1
- package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
- package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js +6 -6
- package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js.map +2 -2
- package/dist/modules/wms/commands/inventory-actions.js +19 -3
- package/dist/modules/wms/commands/inventory-actions.js.map +2 -2
- package/dist/modules/workflows/lib/async-activity-wait.js +43 -0
- package/dist/modules/workflows/lib/async-activity-wait.js.map +7 -0
- package/dist/modules/workflows/lib/parallel-handler.js +20 -14
- package/dist/modules/workflows/lib/parallel-handler.js.map +2 -2
- package/dist/modules/workflows/lib/workflow-executor.js +21 -23
- package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/logout.ts +6 -1
- package/src/modules/auth/api/session/refresh.ts +6 -1
- package/src/modules/catalog/ai-tools/mutation-pack.ts +14 -5
- package/src/modules/catalog/ai-tools/types.ts +1 -0
- package/src/modules/customer_accounts/api/login.ts +15 -7
- package/src/modules/customer_accounts/services/customerUserService.ts +34 -10
- package/src/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.ts +1 -1
- package/src/modules/forms/api/form-submissions/[id]/submit/route.ts +16 -3
- package/src/modules/forms/api/form-submissions/route.ts +21 -2
- package/src/modules/forms/api/public/submissions/[id]/submit/route.ts +7 -0
- package/src/modules/forms/api/runtime-helpers.ts +16 -0
- package/src/modules/forms/data/validators.ts +2 -1
- package/src/modules/forms/i18n/de.json +1 -0
- package/src/modules/forms/i18n/en.json +1 -0
- package/src/modules/forms/i18n/es.json +1 -0
- package/src/modules/forms/i18n/ko.json +1 -0
- package/src/modules/forms/i18n/pl.json +1 -0
- package/src/modules/forms/lib/portal-subject-policy.ts +42 -0
- package/src/modules/forms/lib/runtime-principal.ts +35 -11
- package/src/modules/forms/services/distribution-service.ts +35 -13
- package/src/modules/forms/services/submission-service.ts +12 -0
- package/src/modules/staff/commands/leave-requests.ts +54 -23
- package/src/modules/staff/commands/timesheets-entries.ts +4 -0
- package/src/modules/staff/commands/timesheets-tags.ts +37 -8
- package/src/modules/staff/i18n/de.json +2 -0
- package/src/modules/staff/i18n/en.json +2 -0
- package/src/modules/staff/i18n/es.json +2 -0
- package/src/modules/staff/i18n/ko.json +2 -0
- package/src/modules/staff/i18n/pl.json +2 -0
- package/src/modules/warranty_claims/api/portal/attachments/route.ts +20 -8
- package/src/modules/warranty_claims/commands/claims.ts +1 -1
- package/src/modules/warranty_claims/subscribers/return-shipment-tracking.ts +5 -5
- package/src/modules/wms/commands/inventory-actions.ts +30 -10
- package/src/modules/workflows/lib/async-activity-wait.ts +71 -0
- package/src/modules/workflows/lib/parallel-handler.ts +24 -15
- package/src/modules/workflows/lib/workflow-executor.ts +24 -27
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/forms/data/validators.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\nimport { normalizeEmbedOrigin } from '../lib/embed-frame-policy'\nimport { isNavigableUrl } from '../lib/navigable-url'\n\n/**\n * Forms module shared Zod primitives + Phase 1b/1c command/API schemas.\n *\n * Phase 1a shipped only the building-block primitives. Phase 1b extends with\n * the form definition + version lifecycle command/API schemas. Phase 1c adds\n * the runtime submission schemas.\n */\n\n/**\n * Form key \u2014 stable per-organization slug. 3-64 chars; starts with a lower\n * case letter; contains only `[a-z0-9_-]`. Treated as a contract surface for\n * external integrations; uniqueness is enforced at the DB level by\n * UNIQUE (organization_id, key).\n */\nexport const formKeySchema = z\n .string()\n .min(3)\n .max(64)\n .regex(/^[a-z][a-z0-9_-]*$/, {\n message: 'Form key must start with a lowercase letter and only contain lowercase letters, digits, hyphens, and underscores.',\n })\n\n/**\n * Locale tag \u2014 minimal BCP-47 shape. `en`, `en-US`, `pl`, `pl-PL`, etc.\n * Studio (phase 1b) may expand this once the locale picker ships.\n */\nexport const localeSchema = z\n .string()\n .min(2)\n .max(10)\n .regex(/^[a-z]{2,3}(?:-[A-Z]{2})?$/, {\n message: 'Locale must be a BCP-47 tag like \"en\" or \"en-US\".',\n })\n\n/**\n * Role identifier \u2014 used in `x-om-roles`, `x-om-editable-by`, `x-om-visible-to`,\n * and the form-version `roles` column. Lower-case, slug-shaped, snake-kebab\n * mix tolerated. Common values: `patient`, `clinician`, `admin`, `guardian`.\n */\nexport const roleIdentifierSchema = z\n .string()\n .min(2)\n .max(64)\n .regex(/^[a-z][a-z0-9_-]*$/, {\n message: 'Role identifier must start with a lowercase letter and only contain lowercase letters, digits, hyphens, and underscores.',\n })\n\nexport type FormKey = z.infer<typeof formKeySchema>\nexport type Locale = z.infer<typeof localeSchema>\nexport type RoleIdentifier = z.infer<typeof roleIdentifierSchema>\n\n/**\n * Submission subject identifier. Polymorphic: subject_type is a free-form\n * string tag (`patient`, `customer`, `case`, etc.); subject_id MUST be a\n * UUID. Validation of the (subject_type, subject_id) pair against actual\n * domain entities is the responsibility of the calling module.\n */\nexport const submissionSubjectSchema = z.object({\n subject_type: z.string().min(1).max(64),\n subject_id: z.string().uuid(),\n})\n\n/**\n * Body for `POST /api/form-submissions` \u2014 runtime start.\n */\nexport const submissionStartInputSchema = z.object({\n form_key: formKeySchema,\n subject_type: z.string().min(1).max(64),\n subject_id: z.string().uuid(),\n locale: localeSchema.optional(),\n})\n\nexport type SubmissionStartInput = z.infer<typeof submissionStartInputSchema>\n\n/**\n * Body for `PATCH /api/form-submissions/:id` \u2014 autosave.\n *\n * `patch` is left as `passthrough` because the field-set is dynamic per form\n * version; the SubmissionService enforces `additionalProperties: false`\n * via the AJV validator from the compiler before persisting.\n */\nexport const submissionSaveInputSchema = z.object({\n base_revision_id: z.string().uuid(),\n patch: z.record(z.string(), z.unknown()),\n change_summary: z.string().max(500).optional(),\n})\n\nexport type SubmissionSaveInput = z.infer<typeof submissionSaveInputSchema>\n\n/**\n * Body for `POST /api/form-submissions/:id/submit` \u2014 final submit.\n */\nexport const submissionSubmitInputSchema = z.object({\n base_revision_id: z.string().uuid(),\n submit_metadata: z.record(z.string(), z.unknown()).optional(),\n})\n\nexport type SubmissionSubmitInput = z.infer<typeof submissionSubmitInputSchema>\n\n/**\n * Body for `POST /api/forms/submissions/:submissionId/actors` \u2014 admin assigns\n * a (user, role) pair to a submission. The role MUST be one of the form\n * version's declared roles; the SubmissionService enforces this.\n */\nexport const assignActorInputSchema = z.object({\n user_id: z.string().uuid(),\n role: roleIdentifierSchema,\n})\n\nexport type AssignActorInput = z.infer<typeof assignActorInputSchema>\n\nexport const revokeActorInputSchema = z.object({\n actor_id: z.string().uuid(),\n})\n\nexport type RevokeActorInput = z.infer<typeof revokeActorInputSchema>\n\n// ============================================================================\n// Phase 1b \u2014 Definition Authoring (CRUD + Studio) command + API schemas\n// ============================================================================\n\nconst scopedFields = {\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid(),\n} as const\n\nconst optionalDescription = z\n .string()\n .trim()\n .max(2000)\n .optional()\n .nullable()\n\nconst optionalChangelog = z\n .string()\n .trim()\n .max(4000)\n .optional()\n .nullable()\n\nexport const formStatusSchema = z.enum(['draft', 'active', 'archived'])\nexport const formVersionStatusSchema = z.enum(['draft', 'published', 'archived'])\n\nconst jsonObjectSchema = z.record(z.string(), z.unknown())\n\nconst supportedLocalesSchema = z.array(localeSchema).min(1).max(20)\n\n/**\n * Per-form retention window in days (W5 / DP-6). `null` keeps submissions\n * forever; a positive integer drives the retention-purge worker. Capped at\n * ~100 years so a typo cannot disable purge by accident while still allowing\n * decade-long legal-retention windows.\n */\nconst retentionDaysSchema = z.number().int().min(1).max(36500).optional().nullable()\n\n/** Query schema for `GET /api/forms`. */\nexport const formListQuerySchema = z.object({\n status: z\n .union([formStatusSchema, z.array(formStatusSchema).max(3)])\n .optional(),\n q: z.string().trim().max(200).optional(),\n page: z.coerce.number().int().min(1).optional(),\n pageSize: z.coerce.number().int().min(1).max(100).optional(),\n})\n\nexport type FormListQueryInput = z.infer<typeof formListQuerySchema>\n\n/** Command input for `forms.form.create`. */\nexport const formCreateCommandSchema = z.object({\n ...scopedFields,\n key: formKeySchema,\n name: z.string().trim().min(1).max(200),\n description: optionalDescription,\n defaultLocale: localeSchema,\n supportedLocales: supportedLocalesSchema,\n retentionDays: retentionDaysSchema,\n})\n\nexport type FormCreateCommandInput = z.infer<typeof formCreateCommandSchema>\n\n/**\n * Command input for `forms.form.rename`. Despite the name, this command is the\n * single form-metadata update path: name, description, and (W7) the locale set.\n * `defaultLocale` must be one of `supportedLocales` \u2014 enforced at execute time.\n */\nexport const formRenameCommandSchema = z.object({\n ...scopedFields,\n id: z.string().uuid(),\n name: z.string().trim().min(1).max(200).optional(),\n description: optionalDescription,\n defaultLocale: localeSchema.optional(),\n supportedLocales: supportedLocalesSchema.optional(),\n retentionDays: retentionDaysSchema,\n})\n\nexport type FormRenameCommandInput = z.infer<typeof formRenameCommandSchema>\n\n/** Command input for `forms.form.archive`. */\nexport const formArchiveCommandSchema = z.object({\n ...scopedFields,\n id: z.string().uuid(),\n})\n\nexport type FormArchiveCommandInput = z.infer<typeof formArchiveCommandSchema>\n\n/** Command input for `forms.form.restore`. */\nexport const formRestoreCommandSchema = z.object({\n ...scopedFields,\n id: z.string().uuid(),\n})\n\nexport type FormRestoreCommandInput = z.infer<typeof formRestoreCommandSchema>\n\n/** Command input for `forms.form_version.fork_draft`. */\nexport const formVersionForkDraftCommandSchema = z.object({\n ...scopedFields,\n formId: z.string().uuid(),\n fromVersionId: z.string().uuid().optional().nullable(),\n})\n\nexport type FormVersionForkDraftCommandInput = z.infer<typeof formVersionForkDraftCommandSchema>\n\n/** Command input for `forms.form_version.update_draft`. */\nexport const formVersionUpdateDraftCommandSchema = z.object({\n ...scopedFields,\n formId: z.string().uuid(),\n versionId: z.string().uuid(),\n schema: jsonObjectSchema.optional(),\n uiSchema: jsonObjectSchema.optional(),\n roles: z.array(roleIdentifierSchema).max(50).optional(),\n changelog: optionalChangelog,\n})\n\nexport type FormVersionUpdateDraftCommandInput = z.infer<typeof formVersionUpdateDraftCommandSchema>\n\n/** Command input for `forms.form_version.publish`. */\nexport const formVersionPublishCommandSchema = z.object({\n ...scopedFields,\n formId: z.string().uuid(),\n versionId: z.string().uuid(),\n changelog: z.string().trim().max(4000).optional().nullable(),\n})\n\nexport type FormVersionPublishCommandInput = z.infer<typeof formVersionPublishCommandSchema>\n\n/** Command input for `forms.form_version.archive`. */\nexport const formVersionArchiveCommandSchema = z.object({\n ...scopedFields,\n formId: z.string().uuid(),\n versionId: z.string().uuid(),\n})\n\nexport type FormVersionArchiveCommandInput = z.infer<typeof formVersionArchiveCommandSchema>\n\n/** POST /api/forms request body (route layer fills tenant/organization scope). */\nexport const formCreateRequestSchema = formCreateCommandSchema.omit({\n tenantId: true,\n organizationId: true,\n})\n\n/** PATCH /api/forms/:id request body. */\nexport const formPatchRequestSchema = z.object({\n name: z.string().trim().min(1).max(200).optional(),\n description: optionalDescription,\n defaultLocale: localeSchema.optional(),\n supportedLocales: supportedLocalesSchema.optional(),\n retentionDays: retentionDaysSchema,\n})\n\n/** POST /api/forms/:id/versions/fork request body. */\nexport const formVersionForkRequestSchema = z.object({\n fromVersionId: z.string().uuid().optional().nullable(),\n})\n\n/** PATCH /api/forms/:id/versions/:versionId request body. */\nexport const formVersionPatchRequestSchema = z.object({\n schema: jsonObjectSchema.optional(),\n uiSchema: jsonObjectSchema.optional(),\n roles: z.array(roleIdentifierSchema).max(50).optional(),\n changelog: optionalChangelog,\n})\n\n/** POST /api/forms/:id/versions/:versionId/publish request body. */\nexport const formVersionPublishRequestSchema = z.object({\n changelog: z.string().trim().max(4000).optional().nullable(),\n})\n\n/** GET /api/forms/:id/versions/:versionId/diff query. */\nexport const formVersionDiffQuerySchema = z.object({\n against: z.string().uuid(),\n})\n\nexport type FormCreateRequestInput = z.infer<typeof formCreateRequestSchema>\nexport type FormPatchRequestInput = z.infer<typeof formPatchRequestSchema>\nexport type FormVersionForkRequestInput = z.infer<typeof formVersionForkRequestSchema>\nexport type FormVersionPatchRequestInput = z.infer<typeof formVersionPatchRequestSchema>\nexport type FormVersionPublishRequestInput = z.infer<typeof formVersionPublishRequestSchema>\nexport type FormVersionDiffQueryInput = z.infer<typeof formVersionDiffQuerySchema>\n\nexport type FormStatus = z.infer<typeof formStatusSchema>\nexport type FormVersionStatus = z.infer<typeof formVersionStatusSchema>\n\n// ============================================================================\n// Phase 2d \u2014 Distribution & Anonymous Submission command/API schemas\n// ============================================================================\n\nexport const distributionModeSchema = z.enum(['open', 'personal'])\nexport const distributionStatusSchema = z.enum(['active', 'paused', 'closed'])\n\nexport type FormDistributionMode = z.infer<typeof distributionModeSchema>\nexport type FormDistributionStatus = z.infer<typeof distributionStatusSchema>\n\nconst isoDateTimeSchema = z.string().datetime({ offset: true })\n\n/**\n * Post-submission redirect target. `z.string().url()` alone is not enough: it\n * only asks whether `new URL(...)` parses, so `javascript:`, `data:` and\n * `vbscript:` all pass it \u2014 and this value is assigned to `window.location` by\n * the anonymous public runner, in the app's own origin, under a CSP that allows\n * `'unsafe-inline'`. The scheme check is what makes it safe.\n */\nconst optionalRedirectUrl = z\n .string()\n .url()\n .max(2000)\n .refine((value) => isNavigableUrl(value), {\n message: 'forms.errors.redirect_url_scheme',\n })\n .optional()\n .nullable()\n\n/**\n * A single embed allowlist origin. Validated to the strict origin shape\n * (scheme + host + optional port, https-only except localhost) via the shared\n * `normalizeEmbedOrigin` policy helper so the Zod layer and the runtime CSP\n * builder agree byte-for-byte (R-RS-1).\n */\nconst embedAllowedDomainSchema = z\n .string()\n .trim()\n .min(1)\n .max(253)\n .refine((value) => normalizeEmbedOrigin(value) !== null, {\n message:\n 'Allowed domain must be an https origin like \"https://www.acme.com\" (http is permitted only for localhost). No path, query, or fragment.',\n })\n\n/**\n * `settings.embed` \u2014 opt-in external-website framing config (spec D6). Rides on\n * the existing `forms_distribution.settings` JSON column; no migration. Enabling\n * embedding REQUIRES a non-empty allowlist \u2014 an enabled bag with zero allowed\n * domains is rejected at save time (R-RS-1). `allowedDomains` is NEVER echoed to\n * any public context payload.\n */\nexport const embedSettingsSchema = z\n .object({\n enabled: z.boolean().default(false),\n allowedDomains: z.array(embedAllowedDomainSchema).max(50).default([]),\n theme: z.enum(['light', 'dark', 'auto']).optional(),\n autoResize: z.boolean().default(true),\n })\n .strict()\n .refine((value) => !value.enabled || value.allowedDomains.length > 0, {\n message: 'Embedding cannot be enabled without at least one allowed domain.',\n path: ['allowedDomains'],\n })\n\nexport type EmbedSettingsInput = z.infer<typeof embedSettingsSchema>\n\n/**\n * Distribution `settings` JSON bag. Additive, backward-compatible: known keys\n * (`embed`) are validated; every other key (`captcha`, `completion`, \u2026) passes\n * through untouched so existing settings shapes keep working.\n */\nexport const distributionSettingsSchema = z\n .object({\n embed: embedSettingsSchema.optional(),\n })\n .passthrough()\n\nexport type DistributionSettingsInput = z.infer<typeof distributionSettingsSchema>\n\n/** Command input for `forms.distribution.create`. */\nexport const distributionCreateCommandSchema = z.object({\n ...scopedFields,\n formId: z.string().uuid(),\n mode: distributionModeSchema,\n pinnedVersionId: z.string().uuid().optional().nullable(),\n title: z.string().trim().min(1).max(200).optional().nullable(),\n defaultLocale: localeSchema,\n requireCustomerAuth: z.boolean().optional(),\n allowMultipleSubmissions: z.boolean().optional(),\n maxResponses: z.number().int().positive().optional().nullable(),\n opensAt: isoDateTimeSchema.optional().nullable(),\n closesAt: isoDateTimeSchema.optional().nullable(),\n redirectUrl: optionalRedirectUrl,\n settings: distributionSettingsSchema.optional().nullable(),\n})\n\nexport type FormDistributionCreateCommandInput = z.infer<typeof distributionCreateCommandSchema>\n\n/** Command input for `forms.distribution.update`. */\nexport const distributionUpdateCommandSchema = z.object({\n ...scopedFields,\n distributionId: z.string().uuid(),\n status: distributionStatusSchema.optional(),\n title: z.string().trim().min(1).max(200).optional().nullable(),\n maxResponses: z.number().int().positive().optional().nullable(),\n opensAt: isoDateTimeSchema.optional().nullable(),\n closesAt: isoDateTimeSchema.optional().nullable(),\n redirectUrl: optionalRedirectUrl,\n allowMultipleSubmissions: z.boolean().optional(),\n requireCustomerAuth: z.boolean().optional(),\n settings: distributionSettingsSchema.optional().nullable(),\n})\n\nexport type FormDistributionUpdateCommandInput = z.infer<typeof distributionUpdateCommandSchema>\n\n/** Command input for `forms.distribution.close`. */\nexport const distributionCloseCommandSchema = z.object({\n ...scopedFields,\n distributionId: z.string().uuid(),\n})\n\nexport type FormDistributionCloseCommandInput = z.infer<typeof distributionCloseCommandSchema>\n\nconst invitationRecipientSchema = z.object({\n email: z.string().email().max(320).optional(),\n name: z.string().trim().min(1).max(200).optional(),\n ref: z.string().trim().min(1).max(200).optional(),\n role: roleIdentifierSchema.optional(),\n locale: localeSchema.optional(),\n expiresAt: isoDateTimeSchema.optional(),\n})\n\nexport type FormInvitationRecipientInput = z.infer<typeof invitationRecipientSchema>\n\n/** Command input for `forms.invitation.create` \u2014 bulk recipient creation. */\nexport const invitationCreateCommandSchema = z.object({\n ...scopedFields,\n distributionId: z.string().uuid(),\n recipients: z.array(invitationRecipientSchema).min(1).max(1000),\n})\n\nexport type FormInvitationCreateCommandInput = z.infer<typeof invitationCreateCommandSchema>\n\n/** Command input for `forms.invitation.send`. */\nexport const invitationSendCommandSchema = z.object({\n ...scopedFields,\n invitationId: z.string().uuid(),\n})\n\nexport type FormInvitationSendCommandInput = z.infer<typeof invitationSendCommandSchema>\n\n/** Command input for `forms.invitation.revoke`. */\nexport const invitationRevokeCommandSchema = z.object({\n ...scopedFields,\n invitationId: z.string().uuid(),\n})\n\nexport type FormInvitationRevokeCommandInput = z.infer<typeof invitationRevokeCommandSchema>\n\n/**\n * Body for the public anonymous start endpoint. Exactly one of `slug`\n * (open-mode public link) or `token` (personal-mode invitation) MUST be\n * present; the refine below enforces the disjunction.\n */\nexport const publicStartInputSchema = z\n .object({\n slug: z.string().min(1).max(200).optional(),\n token: z.string().min(1).max(512).optional(),\n locale: localeSchema.optional(),\n captchaToken: z.string().min(1).max(4000).optional(),\n })\n .refine((value) => Boolean(value.slug) !== Boolean(value.token), {\n message: 'Exactly one of \"slug\" or \"token\" must be provided.',\n })\n\nexport type PublicStartInput = z.infer<typeof publicStartInputSchema>\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,SAAS;AAClB,SAAS,4BAA4B;AACrC,SAAS,sBAAsB;AAgBxB,MAAM,gBAAgB,EAC1B,OAAO,EACP,IAAI,CAAC,EACL,IAAI,EAAE,EACN,MAAM,sBAAsB;AAAA,EAC3B,SAAS;AACX,CAAC;AAMI,MAAM,eAAe,EACzB,OAAO,EACP,IAAI,CAAC,EACL,IAAI,EAAE,EACN,MAAM,8BAA8B;AAAA,EACnC,SAAS;AACX,CAAC;AAOI,MAAM,uBAAuB,EACjC,OAAO,EACP,IAAI,CAAC,EACL,IAAI,EAAE,EACN,MAAM,sBAAsB;AAAA,EAC3B,SAAS;AACX,CAAC;
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport { normalizeEmbedOrigin } from '../lib/embed-frame-policy'\nimport { isNavigableUrl } from '../lib/navigable-url'\n\n/**\n * Forms module shared Zod primitives + Phase 1b/1c command/API schemas.\n *\n * Phase 1a shipped only the building-block primitives. Phase 1b extends with\n * the form definition + version lifecycle command/API schemas. Phase 1c adds\n * the runtime submission schemas.\n */\n\n/**\n * Form key \u2014 stable per-organization slug. 3-64 chars; starts with a lower\n * case letter; contains only `[a-z0-9_-]`. Treated as a contract surface for\n * external integrations; uniqueness is enforced at the DB level by\n * UNIQUE (organization_id, key).\n */\nexport const formKeySchema = z\n .string()\n .min(3)\n .max(64)\n .regex(/^[a-z][a-z0-9_-]*$/, {\n message: 'Form key must start with a lowercase letter and only contain lowercase letters, digits, hyphens, and underscores.',\n })\n\n/**\n * Locale tag \u2014 minimal BCP-47 shape. `en`, `en-US`, `pl`, `pl-PL`, etc.\n * Studio (phase 1b) may expand this once the locale picker ships.\n */\nexport const localeSchema = z\n .string()\n .min(2)\n .max(10)\n .regex(/^[a-z]{2,3}(?:-[A-Z]{2})?$/, {\n message: 'Locale must be a BCP-47 tag like \"en\" or \"en-US\".',\n })\n\n/**\n * Role identifier \u2014 used in `x-om-roles`, `x-om-editable-by`, `x-om-visible-to`,\n * and the form-version `roles` column. Lower-case, slug-shaped, snake-kebab\n * mix tolerated. Common values: `patient`, `clinician`, `admin`, `guardian`.\n */\nexport const roleIdentifierSchema = z\n .string()\n .min(2)\n .max(64)\n .regex(/^[a-z][a-z0-9_-]*$/, {\n message: 'Role identifier must start with a lowercase letter and only contain lowercase letters, digits, hyphens, and underscores.',\n })\n\nexport type FormKey = z.infer<typeof formKeySchema>\nexport type Locale = z.infer<typeof localeSchema>\nexport type RoleIdentifier = z.infer<typeof roleIdentifierSchema>\n\n/**\n * Submission subject identifier. Polymorphic: subject_type is a free-form\n * string tag (`patient`, `customer`, `case`, etc.); subject_id MUST be a\n * UUID. Validation of the (subject_type, subject_id) pair against actual\n * domain entities is the responsibility of the calling module; the portal\n * runtime enforces `lib/portal-subject-policy.ts`.\n */\nexport const submissionSubjectSchema = z.object({\n subject_type: z.string().min(1).max(64),\n subject_id: z.string().uuid(),\n})\n\n/**\n * Body for `POST /api/form-submissions` \u2014 runtime start.\n */\nexport const submissionStartInputSchema = z.object({\n form_key: formKeySchema,\n subject_type: z.string().min(1).max(64),\n subject_id: z.string().uuid(),\n locale: localeSchema.optional(),\n})\n\nexport type SubmissionStartInput = z.infer<typeof submissionStartInputSchema>\n\n/**\n * Body for `PATCH /api/form-submissions/:id` \u2014 autosave.\n *\n * `patch` is left as `passthrough` because the field-set is dynamic per form\n * version; the SubmissionService enforces `additionalProperties: false`\n * via the AJV validator from the compiler before persisting.\n */\nexport const submissionSaveInputSchema = z.object({\n base_revision_id: z.string().uuid(),\n patch: z.record(z.string(), z.unknown()),\n change_summary: z.string().max(500).optional(),\n})\n\nexport type SubmissionSaveInput = z.infer<typeof submissionSaveInputSchema>\n\n/**\n * Body for `POST /api/form-submissions/:id/submit` \u2014 final submit.\n */\nexport const submissionSubmitInputSchema = z.object({\n base_revision_id: z.string().uuid(),\n submit_metadata: z.record(z.string(), z.unknown()).optional(),\n})\n\nexport type SubmissionSubmitInput = z.infer<typeof submissionSubmitInputSchema>\n\n/**\n * Body for `POST /api/forms/submissions/:submissionId/actors` \u2014 admin assigns\n * a (user, role) pair to a submission. The role MUST be one of the form\n * version's declared roles; the SubmissionService enforces this.\n */\nexport const assignActorInputSchema = z.object({\n user_id: z.string().uuid(),\n role: roleIdentifierSchema,\n})\n\nexport type AssignActorInput = z.infer<typeof assignActorInputSchema>\n\nexport const revokeActorInputSchema = z.object({\n actor_id: z.string().uuid(),\n})\n\nexport type RevokeActorInput = z.infer<typeof revokeActorInputSchema>\n\n// ============================================================================\n// Phase 1b \u2014 Definition Authoring (CRUD + Studio) command + API schemas\n// ============================================================================\n\nconst scopedFields = {\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid(),\n} as const\n\nconst optionalDescription = z\n .string()\n .trim()\n .max(2000)\n .optional()\n .nullable()\n\nconst optionalChangelog = z\n .string()\n .trim()\n .max(4000)\n .optional()\n .nullable()\n\nexport const formStatusSchema = z.enum(['draft', 'active', 'archived'])\nexport const formVersionStatusSchema = z.enum(['draft', 'published', 'archived'])\n\nconst jsonObjectSchema = z.record(z.string(), z.unknown())\n\nconst supportedLocalesSchema = z.array(localeSchema).min(1).max(20)\n\n/**\n * Per-form retention window in days (W5 / DP-6). `null` keeps submissions\n * forever; a positive integer drives the retention-purge worker. Capped at\n * ~100 years so a typo cannot disable purge by accident while still allowing\n * decade-long legal-retention windows.\n */\nconst retentionDaysSchema = z.number().int().min(1).max(36500).optional().nullable()\n\n/** Query schema for `GET /api/forms`. */\nexport const formListQuerySchema = z.object({\n status: z\n .union([formStatusSchema, z.array(formStatusSchema).max(3)])\n .optional(),\n q: z.string().trim().max(200).optional(),\n page: z.coerce.number().int().min(1).optional(),\n pageSize: z.coerce.number().int().min(1).max(100).optional(),\n})\n\nexport type FormListQueryInput = z.infer<typeof formListQuerySchema>\n\n/** Command input for `forms.form.create`. */\nexport const formCreateCommandSchema = z.object({\n ...scopedFields,\n key: formKeySchema,\n name: z.string().trim().min(1).max(200),\n description: optionalDescription,\n defaultLocale: localeSchema,\n supportedLocales: supportedLocalesSchema,\n retentionDays: retentionDaysSchema,\n})\n\nexport type FormCreateCommandInput = z.infer<typeof formCreateCommandSchema>\n\n/**\n * Command input for `forms.form.rename`. Despite the name, this command is the\n * single form-metadata update path: name, description, and (W7) the locale set.\n * `defaultLocale` must be one of `supportedLocales` \u2014 enforced at execute time.\n */\nexport const formRenameCommandSchema = z.object({\n ...scopedFields,\n id: z.string().uuid(),\n name: z.string().trim().min(1).max(200).optional(),\n description: optionalDescription,\n defaultLocale: localeSchema.optional(),\n supportedLocales: supportedLocalesSchema.optional(),\n retentionDays: retentionDaysSchema,\n})\n\nexport type FormRenameCommandInput = z.infer<typeof formRenameCommandSchema>\n\n/** Command input for `forms.form.archive`. */\nexport const formArchiveCommandSchema = z.object({\n ...scopedFields,\n id: z.string().uuid(),\n})\n\nexport type FormArchiveCommandInput = z.infer<typeof formArchiveCommandSchema>\n\n/** Command input for `forms.form.restore`. */\nexport const formRestoreCommandSchema = z.object({\n ...scopedFields,\n id: z.string().uuid(),\n})\n\nexport type FormRestoreCommandInput = z.infer<typeof formRestoreCommandSchema>\n\n/** Command input for `forms.form_version.fork_draft`. */\nexport const formVersionForkDraftCommandSchema = z.object({\n ...scopedFields,\n formId: z.string().uuid(),\n fromVersionId: z.string().uuid().optional().nullable(),\n})\n\nexport type FormVersionForkDraftCommandInput = z.infer<typeof formVersionForkDraftCommandSchema>\n\n/** Command input for `forms.form_version.update_draft`. */\nexport const formVersionUpdateDraftCommandSchema = z.object({\n ...scopedFields,\n formId: z.string().uuid(),\n versionId: z.string().uuid(),\n schema: jsonObjectSchema.optional(),\n uiSchema: jsonObjectSchema.optional(),\n roles: z.array(roleIdentifierSchema).max(50).optional(),\n changelog: optionalChangelog,\n})\n\nexport type FormVersionUpdateDraftCommandInput = z.infer<typeof formVersionUpdateDraftCommandSchema>\n\n/** Command input for `forms.form_version.publish`. */\nexport const formVersionPublishCommandSchema = z.object({\n ...scopedFields,\n formId: z.string().uuid(),\n versionId: z.string().uuid(),\n changelog: z.string().trim().max(4000).optional().nullable(),\n})\n\nexport type FormVersionPublishCommandInput = z.infer<typeof formVersionPublishCommandSchema>\n\n/** Command input for `forms.form_version.archive`. */\nexport const formVersionArchiveCommandSchema = z.object({\n ...scopedFields,\n formId: z.string().uuid(),\n versionId: z.string().uuid(),\n})\n\nexport type FormVersionArchiveCommandInput = z.infer<typeof formVersionArchiveCommandSchema>\n\n/** POST /api/forms request body (route layer fills tenant/organization scope). */\nexport const formCreateRequestSchema = formCreateCommandSchema.omit({\n tenantId: true,\n organizationId: true,\n})\n\n/** PATCH /api/forms/:id request body. */\nexport const formPatchRequestSchema = z.object({\n name: z.string().trim().min(1).max(200).optional(),\n description: optionalDescription,\n defaultLocale: localeSchema.optional(),\n supportedLocales: supportedLocalesSchema.optional(),\n retentionDays: retentionDaysSchema,\n})\n\n/** POST /api/forms/:id/versions/fork request body. */\nexport const formVersionForkRequestSchema = z.object({\n fromVersionId: z.string().uuid().optional().nullable(),\n})\n\n/** PATCH /api/forms/:id/versions/:versionId request body. */\nexport const formVersionPatchRequestSchema = z.object({\n schema: jsonObjectSchema.optional(),\n uiSchema: jsonObjectSchema.optional(),\n roles: z.array(roleIdentifierSchema).max(50).optional(),\n changelog: optionalChangelog,\n})\n\n/** POST /api/forms/:id/versions/:versionId/publish request body. */\nexport const formVersionPublishRequestSchema = z.object({\n changelog: z.string().trim().max(4000).optional().nullable(),\n})\n\n/** GET /api/forms/:id/versions/:versionId/diff query. */\nexport const formVersionDiffQuerySchema = z.object({\n against: z.string().uuid(),\n})\n\nexport type FormCreateRequestInput = z.infer<typeof formCreateRequestSchema>\nexport type FormPatchRequestInput = z.infer<typeof formPatchRequestSchema>\nexport type FormVersionForkRequestInput = z.infer<typeof formVersionForkRequestSchema>\nexport type FormVersionPatchRequestInput = z.infer<typeof formVersionPatchRequestSchema>\nexport type FormVersionPublishRequestInput = z.infer<typeof formVersionPublishRequestSchema>\nexport type FormVersionDiffQueryInput = z.infer<typeof formVersionDiffQuerySchema>\n\nexport type FormStatus = z.infer<typeof formStatusSchema>\nexport type FormVersionStatus = z.infer<typeof formVersionStatusSchema>\n\n// ============================================================================\n// Phase 2d \u2014 Distribution & Anonymous Submission command/API schemas\n// ============================================================================\n\nexport const distributionModeSchema = z.enum(['open', 'personal'])\nexport const distributionStatusSchema = z.enum(['active', 'paused', 'closed'])\n\nexport type FormDistributionMode = z.infer<typeof distributionModeSchema>\nexport type FormDistributionStatus = z.infer<typeof distributionStatusSchema>\n\nconst isoDateTimeSchema = z.string().datetime({ offset: true })\n\n/**\n * Post-submission redirect target. `z.string().url()` alone is not enough: it\n * only asks whether `new URL(...)` parses, so `javascript:`, `data:` and\n * `vbscript:` all pass it \u2014 and this value is assigned to `window.location` by\n * the anonymous public runner, in the app's own origin, under a CSP that allows\n * `'unsafe-inline'`. The scheme check is what makes it safe.\n */\nconst optionalRedirectUrl = z\n .string()\n .url()\n .max(2000)\n .refine((value) => isNavigableUrl(value), {\n message: 'forms.errors.redirect_url_scheme',\n })\n .optional()\n .nullable()\n\n/**\n * A single embed allowlist origin. Validated to the strict origin shape\n * (scheme + host + optional port, https-only except localhost) via the shared\n * `normalizeEmbedOrigin` policy helper so the Zod layer and the runtime CSP\n * builder agree byte-for-byte (R-RS-1).\n */\nconst embedAllowedDomainSchema = z\n .string()\n .trim()\n .min(1)\n .max(253)\n .refine((value) => normalizeEmbedOrigin(value) !== null, {\n message:\n 'Allowed domain must be an https origin like \"https://www.acme.com\" (http is permitted only for localhost). No path, query, or fragment.',\n })\n\n/**\n * `settings.embed` \u2014 opt-in external-website framing config (spec D6). Rides on\n * the existing `forms_distribution.settings` JSON column; no migration. Enabling\n * embedding REQUIRES a non-empty allowlist \u2014 an enabled bag with zero allowed\n * domains is rejected at save time (R-RS-1). `allowedDomains` is NEVER echoed to\n * any public context payload.\n */\nexport const embedSettingsSchema = z\n .object({\n enabled: z.boolean().default(false),\n allowedDomains: z.array(embedAllowedDomainSchema).max(50).default([]),\n theme: z.enum(['light', 'dark', 'auto']).optional(),\n autoResize: z.boolean().default(true),\n })\n .strict()\n .refine((value) => !value.enabled || value.allowedDomains.length > 0, {\n message: 'Embedding cannot be enabled without at least one allowed domain.',\n path: ['allowedDomains'],\n })\n\nexport type EmbedSettingsInput = z.infer<typeof embedSettingsSchema>\n\n/**\n * Distribution `settings` JSON bag. Additive, backward-compatible: known keys\n * (`embed`) are validated; every other key (`captcha`, `completion`, \u2026) passes\n * through untouched so existing settings shapes keep working.\n */\nexport const distributionSettingsSchema = z\n .object({\n embed: embedSettingsSchema.optional(),\n })\n .passthrough()\n\nexport type DistributionSettingsInput = z.infer<typeof distributionSettingsSchema>\n\n/** Command input for `forms.distribution.create`. */\nexport const distributionCreateCommandSchema = z.object({\n ...scopedFields,\n formId: z.string().uuid(),\n mode: distributionModeSchema,\n pinnedVersionId: z.string().uuid().optional().nullable(),\n title: z.string().trim().min(1).max(200).optional().nullable(),\n defaultLocale: localeSchema,\n requireCustomerAuth: z.boolean().optional(),\n allowMultipleSubmissions: z.boolean().optional(),\n maxResponses: z.number().int().positive().optional().nullable(),\n opensAt: isoDateTimeSchema.optional().nullable(),\n closesAt: isoDateTimeSchema.optional().nullable(),\n redirectUrl: optionalRedirectUrl,\n settings: distributionSettingsSchema.optional().nullable(),\n})\n\nexport type FormDistributionCreateCommandInput = z.infer<typeof distributionCreateCommandSchema>\n\n/** Command input for `forms.distribution.update`. */\nexport const distributionUpdateCommandSchema = z.object({\n ...scopedFields,\n distributionId: z.string().uuid(),\n status: distributionStatusSchema.optional(),\n title: z.string().trim().min(1).max(200).optional().nullable(),\n maxResponses: z.number().int().positive().optional().nullable(),\n opensAt: isoDateTimeSchema.optional().nullable(),\n closesAt: isoDateTimeSchema.optional().nullable(),\n redirectUrl: optionalRedirectUrl,\n allowMultipleSubmissions: z.boolean().optional(),\n requireCustomerAuth: z.boolean().optional(),\n settings: distributionSettingsSchema.optional().nullable(),\n})\n\nexport type FormDistributionUpdateCommandInput = z.infer<typeof distributionUpdateCommandSchema>\n\n/** Command input for `forms.distribution.close`. */\nexport const distributionCloseCommandSchema = z.object({\n ...scopedFields,\n distributionId: z.string().uuid(),\n})\n\nexport type FormDistributionCloseCommandInput = z.infer<typeof distributionCloseCommandSchema>\n\nconst invitationRecipientSchema = z.object({\n email: z.string().email().max(320).optional(),\n name: z.string().trim().min(1).max(200).optional(),\n ref: z.string().trim().min(1).max(200).optional(),\n role: roleIdentifierSchema.optional(),\n locale: localeSchema.optional(),\n expiresAt: isoDateTimeSchema.optional(),\n})\n\nexport type FormInvitationRecipientInput = z.infer<typeof invitationRecipientSchema>\n\n/** Command input for `forms.invitation.create` \u2014 bulk recipient creation. */\nexport const invitationCreateCommandSchema = z.object({\n ...scopedFields,\n distributionId: z.string().uuid(),\n recipients: z.array(invitationRecipientSchema).min(1).max(1000),\n})\n\nexport type FormInvitationCreateCommandInput = z.infer<typeof invitationCreateCommandSchema>\n\n/** Command input for `forms.invitation.send`. */\nexport const invitationSendCommandSchema = z.object({\n ...scopedFields,\n invitationId: z.string().uuid(),\n})\n\nexport type FormInvitationSendCommandInput = z.infer<typeof invitationSendCommandSchema>\n\n/** Command input for `forms.invitation.revoke`. */\nexport const invitationRevokeCommandSchema = z.object({\n ...scopedFields,\n invitationId: z.string().uuid(),\n})\n\nexport type FormInvitationRevokeCommandInput = z.infer<typeof invitationRevokeCommandSchema>\n\n/**\n * Body for the public anonymous start endpoint. Exactly one of `slug`\n * (open-mode public link) or `token` (personal-mode invitation) MUST be\n * present; the refine below enforces the disjunction.\n */\nexport const publicStartInputSchema = z\n .object({\n slug: z.string().min(1).max(200).optional(),\n token: z.string().min(1).max(512).optional(),\n locale: localeSchema.optional(),\n captchaToken: z.string().min(1).max(4000).optional(),\n })\n .refine((value) => Boolean(value.slug) !== Boolean(value.token), {\n message: 'Exactly one of \"slug\" or \"token\" must be provided.',\n })\n\nexport type PublicStartInput = z.infer<typeof publicStartInputSchema>\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const PORTAL_SUBJECT_TYPE = "customer";
|
|
2
|
+
function isSameId(left, right) {
|
|
3
|
+
return left.toLowerCase() === right.toLowerCase();
|
|
4
|
+
}
|
|
5
|
+
function isPortalSubjectAllowed(customerUserId, subject) {
|
|
6
|
+
return subject.subjectType === PORTAL_SUBJECT_TYPE && isSameId(subject.subjectId, customerUserId);
|
|
7
|
+
}
|
|
8
|
+
function isPortalSubmitAllowed(customerUserId, submission) {
|
|
9
|
+
if (!isSameId(submission.startedBy, customerUserId)) return true;
|
|
10
|
+
return isPortalSubjectAllowed(customerUserId, submission);
|
|
11
|
+
}
|
|
12
|
+
export {
|
|
13
|
+
PORTAL_SUBJECT_TYPE,
|
|
14
|
+
isPortalSubjectAllowed,
|
|
15
|
+
isPortalSubmitAllowed
|
|
16
|
+
};
|
|
17
|
+
//# sourceMappingURL=portal-subject-policy.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/forms/lib/portal-subject-policy.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Portal subject policy (#7224).\n *\n * A portal customer may start a submission, and finalize a submission they\n * started themselves, only for the subject that identifies them: the\n * `customer` subject whose id is their own customer user id (`auth.sub`).\n * Any other pair is refused before anything is persisted, because the\n * subject is what the consent projector attributes signatures to.\n *\n * A submission started by someone else reached the caller through a staff\n * actor assignment (`forms.submissions.manage`), so staff already vouched for\n * that pairing and the submit is not re-checked here. Token (invitation)\n * principals carry a server-derived subject and are out of scope.\n *\n * The start route applies `isPortalSubjectAllowed` before anything is\n * persisted; `SubmissionService.submit()` applies `isPortalSubmitAllowed`\n * inside its locked transaction when the caller is a portal customer.\n */\n\nexport const PORTAL_SUBJECT_TYPE = 'customer'\n\nexport type PortalSubject = {\n subjectType: string\n subjectId: string\n}\n\nexport type PortalSubmissionSubject = PortalSubject & {\n startedBy: string\n}\n\nfunction isSameId(left: string, right: string): boolean {\n return left.toLowerCase() === right.toLowerCase()\n}\n\nexport function isPortalSubjectAllowed(customerUserId: string, subject: PortalSubject): boolean {\n return subject.subjectType === PORTAL_SUBJECT_TYPE && isSameId(subject.subjectId, customerUserId)\n}\n\nexport function isPortalSubmitAllowed(customerUserId: string, submission: PortalSubmissionSubject): boolean {\n if (!isSameId(submission.startedBy, customerUserId)) return true\n return isPortalSubjectAllowed(customerUserId, submission)\n}\n"],
|
|
5
|
+
"mappings": "AAmBO,MAAM,sBAAsB;AAWnC,SAAS,SAAS,MAAc,OAAwB;AACtD,SAAO,KAAK,YAAY,MAAM,MAAM,YAAY;AAClD;AAEO,SAAS,uBAAuB,gBAAwB,SAAiC;AAC9F,SAAO,QAAQ,gBAAgB,uBAAuB,SAAS,QAAQ,WAAW,cAAc;AAClG;AAEO,SAAS,sBAAsB,gBAAwB,YAA8C;AAC1G,MAAI,CAAC,SAAS,WAAW,WAAW,cAAc,EAAG,QAAO;AAC5D,SAAO,uBAAuB,gBAAgB,UAAU;AAC1D;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getCustomerAuthFromRequest } from "@open-mercato/core/modules/customer_accounts/lib/customerAuth";
|
|
2
|
-
import { FormSubmission, FormSubmissionActor } from "../data/entities.js";
|
|
2
|
+
import { FormInvitation, FormSubmission, FormSubmissionActor } from "../data/entities.js";
|
|
3
|
+
import { INVITATION_ACCESS_CLOSED_STATUSES } from "../services/distribution-service.js";
|
|
3
4
|
import { verifyAccessToken } from "../services/distribution-token.js";
|
|
4
5
|
function readBearerToken(req) {
|
|
5
6
|
const header = req.headers.get("authorization") ?? req.headers.get("Authorization");
|
|
@@ -19,16 +20,32 @@ async function resolveRuntimePrincipal(args) {
|
|
|
19
20
|
id: submissionId,
|
|
20
21
|
deletedAt: null
|
|
21
22
|
});
|
|
22
|
-
if (submission)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
if (!submission) return null;
|
|
24
|
+
const invitation = await em.findOne(FormInvitation, {
|
|
25
|
+
id: verified.invitationId,
|
|
26
|
+
organizationId: submission.organizationId,
|
|
27
|
+
tenantId: submission.tenantId,
|
|
28
|
+
deletedAt: null
|
|
29
|
+
});
|
|
30
|
+
if (!invitation || invitation.submissionId !== submissionId || INVITATION_ACCESS_CLOSED_STATUSES.has(invitation.status)) {
|
|
31
|
+
return null;
|
|
31
32
|
}
|
|
33
|
+
const actor = await em.findOne(FormSubmissionActor, {
|
|
34
|
+
submissionId,
|
|
35
|
+
organizationId: submission.organizationId,
|
|
36
|
+
userId: invitation.id,
|
|
37
|
+
revokedAt: null,
|
|
38
|
+
deletedAt: null
|
|
39
|
+
});
|
|
40
|
+
if (!actor) return null;
|
|
41
|
+
return {
|
|
42
|
+
source: "token",
|
|
43
|
+
principal: verified.invitationId,
|
|
44
|
+
role: actor.role,
|
|
45
|
+
organizationId: submission.organizationId,
|
|
46
|
+
tenantId: submission.tenantId,
|
|
47
|
+
submissionId
|
|
48
|
+
};
|
|
32
49
|
}
|
|
33
50
|
return null;
|
|
34
51
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/forms/lib/runtime-principal.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Public runtime principal guard \u2014 phase 2d.\n *\n * Resolves the acting principal for a public-runtime submission request from\n * EITHER a submission access token (`Authorization: Bearer <token>`) OR a\n * portal customer session, in that order.\n *\n * Critical invariant (R-2d-4): when the principal comes from an access token,\n * the org/tenant are NEVER trusted from the token body \u2014 they are re-derived\n * from the persisted `forms_form_submission` row. The token only authorizes a\n * single `(submissionId, invitationId, role)` triple; it asserts the token's\n * submission matches the route's submission before trusting anything.\n */\n\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getCustomerAuthFromRequest } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'\nimport { FormSubmission, FormSubmissionActor } from '../data/entities'\nimport { verifyAccessToken } from '../services/distribution-token'\n\nexport type RuntimePrincipal = {\n source: 'token' | 'customer'\n principal: string\n role: string | null\n organizationId: string\n tenantId: string\n submissionId: string\n}\n\nexport type ResolveRuntimePrincipalArgs = {\n req: Request\n submissionId: string\n em: EntityManager\n}\n\nfunction readBearerToken(req: Request): string | null {\n const header = req.headers.get('authorization') ?? req.headers.get('Authorization')\n if (!header) return null\n const match = /^Bearer\\s+(.+)$/i.exec(header.trim())\n if (!match) return null\n const token = match[1].trim()\n return token.length > 0 ? token : null\n}\n\nexport async function resolveRuntimePrincipal(\n args: ResolveRuntimePrincipalArgs,\n): Promise<RuntimePrincipal | null> {\n const { req, submissionId, em } = args\n\n const bearer = readBearerToken(req)\n if (bearer) {\n const verified = verifyAccessToken(bearer)\n if (verified.ok && verified.submissionId === submissionId && verified.invitationId) {\n const submission = await em.findOne(FormSubmission, {\n id: submissionId,\n deletedAt: null,\n })\n if (submission) {\n return {\n
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["/**\n * Public runtime principal guard \u2014 phase 2d.\n *\n * Resolves the acting principal for a public-runtime submission request from\n * EITHER a submission access token (`Authorization: Bearer <token>`) OR a\n * portal customer session, in that order.\n *\n * Critical invariant (R-2d-4): when the principal comes from an access token,\n * the org/tenant are NEVER trusted from the token body \u2014 they are re-derived\n * from the persisted `forms_form_submission` row. The token only authorizes a\n * single `(submissionId, invitationId, role)` triple; it asserts the token's\n * submission matches the route's submission before trusting anything. The\n * token is also only honoured while its invitation is live (not revoked or\n * expired) and its actor row on the submission is active; the role comes from\n * that actor row, never from the token.\n */\n\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getCustomerAuthFromRequest } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'\nimport { FormInvitation, FormSubmission, FormSubmissionActor } from '../data/entities'\nimport { INVITATION_ACCESS_CLOSED_STATUSES } from '../services/distribution-service'\nimport { verifyAccessToken } from '../services/distribution-token'\n\nexport type RuntimePrincipal = {\n source: 'token' | 'customer'\n principal: string\n role: string | null\n organizationId: string\n tenantId: string\n submissionId: string\n}\n\nexport type ResolveRuntimePrincipalArgs = {\n req: Request\n submissionId: string\n em: EntityManager\n}\n\nfunction readBearerToken(req: Request): string | null {\n const header = req.headers.get('authorization') ?? req.headers.get('Authorization')\n if (!header) return null\n const match = /^Bearer\\s+(.+)$/i.exec(header.trim())\n if (!match) return null\n const token = match[1].trim()\n return token.length > 0 ? token : null\n}\n\nexport async function resolveRuntimePrincipal(\n args: ResolveRuntimePrincipalArgs,\n): Promise<RuntimePrincipal | null> {\n const { req, submissionId, em } = args\n\n const bearer = readBearerToken(req)\n if (bearer) {\n const verified = verifyAccessToken(bearer)\n if (verified.ok && verified.submissionId === submissionId && verified.invitationId) {\n const submission = await em.findOne(FormSubmission, {\n id: submissionId,\n deletedAt: null,\n })\n if (!submission) return null\n const invitation = await em.findOne(FormInvitation, {\n id: verified.invitationId,\n organizationId: submission.organizationId,\n tenantId: submission.tenantId,\n deletedAt: null,\n })\n if (\n !invitation ||\n invitation.submissionId !== submissionId ||\n INVITATION_ACCESS_CLOSED_STATUSES.has(invitation.status)\n ) {\n return null\n }\n const actor = await em.findOne(FormSubmissionActor, {\n submissionId,\n organizationId: submission.organizationId,\n userId: invitation.id,\n revokedAt: null,\n deletedAt: null,\n })\n if (!actor) return null\n return {\n source: 'token',\n principal: verified.invitationId,\n role: actor.role,\n organizationId: submission.organizationId,\n tenantId: submission.tenantId,\n submissionId,\n }\n }\n // A present-but-invalid bearer token does not fall through to customer\n // auth \u2014 it is an explicit (failed) authorization attempt.\n return null\n }\n\n const auth = await getCustomerAuthFromRequest(req)\n if (auth) {\n // A portal session authenticates the customer; it does not by itself\n // authorize them on THIS submission. Authorization is the active actor row,\n // exactly as `SubmissionService.save()` requires \u2014 and the actor's role is\n // what the read-slice is computed from. Without this lookup the branch\n // returned `role: null`, which `getCurrent` used to read as \"do not slice\",\n // so any portal customer could read any submission in their own\n // organization fully decrypted and unsliced.\n const actor = await em.findOne(FormSubmissionActor, {\n submissionId,\n organizationId: auth.orgId,\n userId: auth.sub,\n revokedAt: null,\n deletedAt: null,\n })\n if (!actor) return null\n return {\n source: 'customer',\n principal: auth.sub,\n role: actor.role,\n organizationId: auth.orgId,\n tenantId: auth.tenantId,\n submissionId,\n }\n }\n\n return null\n}\n"],
|
|
5
|
+
"mappings": "AAkBA,SAAS,kCAAkC;AAC3C,SAAS,gBAAgB,gBAAgB,2BAA2B;AACpE,SAAS,yCAAyC;AAClD,SAAS,yBAAyB;AAiBlC,SAAS,gBAAgB,KAA6B;AACpD,QAAM,SAAS,IAAI,QAAQ,IAAI,eAAe,KAAK,IAAI,QAAQ,IAAI,eAAe;AAClF,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,QAAQ,mBAAmB,KAAK,OAAO,KAAK,CAAC;AACnD,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,QAAQ,MAAM,CAAC,EAAE,KAAK;AAC5B,SAAO,MAAM,SAAS,IAAI,QAAQ;AACpC;AAEA,eAAsB,wBACpB,MACkC;AAClC,QAAM,EAAE,KAAK,cAAc,GAAG,IAAI;AAElC,QAAM,SAAS,gBAAgB,GAAG;AAClC,MAAI,QAAQ;AACV,UAAM,WAAW,kBAAkB,MAAM;AACzC,QAAI,SAAS,MAAM,SAAS,iBAAiB,gBAAgB,SAAS,cAAc;AAClF,YAAM,aAAa,MAAM,GAAG,QAAQ,gBAAgB;AAAA,QAClD,IAAI;AAAA,QACJ,WAAW;AAAA,MACb,CAAC;AACD,UAAI,CAAC,WAAY,QAAO;AACxB,YAAM,aAAa,MAAM,GAAG,QAAQ,gBAAgB;AAAA,QAClD,IAAI,SAAS;AAAA,QACb,gBAAgB,WAAW;AAAA,QAC3B,UAAU,WAAW;AAAA,QACrB,WAAW;AAAA,MACb,CAAC;AACD,UACE,CAAC,cACD,WAAW,iBAAiB,gBAC5B,kCAAkC,IAAI,WAAW,MAAM,GACvD;AACA,eAAO;AAAA,MACT;AACA,YAAM,QAAQ,MAAM,GAAG,QAAQ,qBAAqB;AAAA,QAClD;AAAA,QACA,gBAAgB,WAAW;AAAA,QAC3B,QAAQ,WAAW;AAAA,QACnB,WAAW;AAAA,QACX,WAAW;AAAA,MACb,CAAC;AACD,UAAI,CAAC,MAAO,QAAO;AACnB,aAAO;AAAA,QACL,QAAQ;AAAA,QACR,WAAW,SAAS;AAAA,QACpB,MAAM,MAAM;AAAA,QACZ,gBAAgB,WAAW;AAAA,QAC3B,UAAU,WAAW;AAAA,QACrB;AAAA,MACF;AAAA,IACF;AAGA,WAAO;AAAA,EACT;AAEA,QAAM,OAAO,MAAM,2BAA2B,GAAG;AACjD,MAAI,MAAM;AAQR,UAAM,QAAQ,MAAM,GAAG,QAAQ,qBAAqB;AAAA,MAClD;AAAA,MACA,gBAAgB,KAAK;AAAA,MACrB,QAAQ,KAAK;AAAA,MACb,WAAW;AAAA,MACX,WAAW;AAAA,IACb,CAAC;AACD,QAAI,CAAC,MAAO,QAAO;AACnB,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,WAAW,KAAK;AAAA,MAChB,MAAM,MAAM;AAAA,MACZ,gBAAgB,KAAK;AAAA,MACrB,UAAU,KAAK;AAAA,MACf;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -31,7 +31,8 @@ const noopLogger = {
|
|
|
31
31
|
error: () => {
|
|
32
32
|
}
|
|
33
33
|
};
|
|
34
|
-
const
|
|
34
|
+
const INVITATION_ACCESS_CLOSED_STATUSES = /* @__PURE__ */ new Set(["revoked", "expired"]);
|
|
35
|
+
const INVITATION_TERMINAL_STATUSES = /* @__PURE__ */ new Set(["submitted", ...INVITATION_ACCESS_CLOSED_STATUSES]);
|
|
35
36
|
class DistributionService {
|
|
36
37
|
constructor(options) {
|
|
37
38
|
this.emFactory = options.emFactory;
|
|
@@ -254,20 +255,40 @@ class DistributionService {
|
|
|
254
255
|
// --------------------------------------------------------------------------
|
|
255
256
|
async markInvitationSubmitted(args) {
|
|
256
257
|
const em = this.emFactory();
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
258
|
+
const marked = await em.transactional(async (trx) => {
|
|
259
|
+
const invitation = await trx.findOne(
|
|
260
|
+
FormInvitation,
|
|
261
|
+
{
|
|
262
|
+
id: args.invitationId,
|
|
263
|
+
organizationId: args.organizationId,
|
|
264
|
+
tenantId: args.tenantId,
|
|
265
|
+
deletedAt: null
|
|
266
|
+
},
|
|
267
|
+
{ lockMode: LockMode.PESSIMISTIC_WRITE }
|
|
268
|
+
);
|
|
269
|
+
if (!invitation) {
|
|
270
|
+
throw new DistributionServiceError("NOT_FOUND", "Invitation not found.", 404);
|
|
271
|
+
}
|
|
272
|
+
if (INVITATION_ACCESS_CLOSED_STATUSES.has(invitation.status)) {
|
|
273
|
+
this.logger.warn(
|
|
274
|
+
{
|
|
275
|
+
event: "forms.distribution.invitation_submit_skipped",
|
|
276
|
+
invitationId: invitation.id,
|
|
277
|
+
submissionId: args.submissionId,
|
|
278
|
+
status: invitation.status
|
|
279
|
+
},
|
|
280
|
+
"Invitation access closed before submit; status left unchanged."
|
|
281
|
+
);
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
invitation.status = "submitted";
|
|
285
|
+
invitation.submittedAt = this.now();
|
|
286
|
+
await trx.flush();
|
|
287
|
+
return true;
|
|
262
288
|
});
|
|
263
|
-
if (!
|
|
264
|
-
throw new DistributionServiceError("NOT_FOUND", "Invitation not found.", 404);
|
|
265
|
-
}
|
|
266
|
-
invitation.status = "submitted";
|
|
267
|
-
invitation.submittedAt = this.now();
|
|
268
|
-
await em.flush();
|
|
289
|
+
if (!marked) return;
|
|
269
290
|
await this.safeEmit("forms.invitation.submitted", {
|
|
270
|
-
invitationId:
|
|
291
|
+
invitationId: args.invitationId,
|
|
271
292
|
submissionId: args.submissionId
|
|
272
293
|
});
|
|
273
294
|
}
|
|
@@ -361,6 +382,7 @@ function isDistributionServiceError(error) {
|
|
|
361
382
|
export {
|
|
362
383
|
DistributionService,
|
|
363
384
|
DistributionServiceError,
|
|
385
|
+
INVITATION_ACCESS_CLOSED_STATUSES,
|
|
364
386
|
isDistributionServiceError
|
|
365
387
|
};
|
|
366
388
|
//# sourceMappingURL=distribution-service.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/forms/services/distribution-service.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Forms DistributionService \u2014 phase 2d.\n *\n * Orchestrates the distribution / invitation layer that puts a published form\n * in front of an unauthenticated participant and bootstraps an anonymous\n * submission by delegating to the unchanged `SubmissionService`.\n *\n * Invariants:\n * - Every query is tenant-scoped (`organization_id` + `tenant_id`) EXCEPT the\n * two deliberately-global lookups \u2014 by random `public_slug` and by\n * `token_hash` \u2014 both high-entropy and globally unique in practice. After a\n * global lookup the org/tenant scope is taken from the found row and used\n * for every subsequent query.\n * - Recipient PII (`recipient_email` / `recipient_name`) is encrypted via the\n * GLOBAL pipeline; reads that need the plaintext use `findOneWithDecryption`.\n * - The submission access token never carries org/tenant \u2014 those are\n * re-derived from the persisted submission downstream (see runtime-principal).\n */\n\nimport { LockMode } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { z } from 'zod'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport {\n Form,\n FormDistribution,\n FormInvitation,\n FormVersion,\n} from '../data/entities'\nimport type { CompiledFormVersion } from './form-version-compiler'\nimport type { SubmissionService, SubmissionViewModel } from './submission-service'\nimport type { StructuredLogger } from '../lib/log-redaction'\nimport { buildFrameAncestorsCsp, isEmbedEnabled, readEmbedSettings } from '../lib/embed-frame-policy'\nimport { formsEventPayloadSchemas } from '../events-payloads'\nimport {\n getAccessTokenTtlSeconds,\n hashInvitationToken,\n signAccessToken,\n} from './distribution-token'\n\nexport type DistributionServiceErrorCode =\n | 'NOT_FOUND'\n | 'GONE'\n | 'INVALID_STATUS'\n\n/**\n * `Symbol.for` so the brand survives module duplication across bundle\n * boundaries. `instanceof` does NOT: Next can load this module in more than\n * one chunk, and once a second copy of the class exists an error thrown\n * through one copy fails `instanceof` against the other \u2014 silently\n * downgrading a mapped domain error to a 500. Mirrors `CrudHttpError`.\n */\nconst DISTRIBUTION_SERVICE_ERROR_MARKER = Symbol.for('@open-mercato/forms/DistributionServiceError')\n\nexport class DistributionServiceError extends Error {\n readonly [DISTRIBUTION_SERVICE_ERROR_MARKER] = true\n readonly code: DistributionServiceErrorCode\n readonly httpStatus: number\n readonly details?: Record<string, unknown>\n\n constructor(\n code: DistributionServiceErrorCode,\n message: string,\n httpStatus: number,\n details?: Record<string, unknown>,\n ) {\n super(message)\n this.name = 'DistributionServiceError'\n this.code = code\n this.httpStatus = httpStatus\n this.details = details\n }\n}\n\ntype DistributionEvents = {\n 'forms.invitation.opened': z.infer<typeof formsEventPayloadSchemas['forms.invitation.opened']>\n 'forms.invitation.submitted': z.infer<typeof formsEventPayloadSchemas['forms.invitation.submitted']>\n}\n\nexport type DistributionServiceOptions = {\n emFactory: () => EntityManager\n submissionService: SubmissionService\n /** Pluggable, schema-validated event emitter \u2014 wired in di.ts. */\n emitEvent: <K extends keyof DistributionEvents>(eventId: K, payload: DistributionEvents[K]) => Promise<void> | void\n now?: () => Date\n logger?: StructuredLogger\n}\n\nconst noopLogger: StructuredLogger = {\n info: () => {},\n warn: () => {},\n error: () => {},\n}\n\nconst INVITATION_TERMINAL_STATUSES: ReadonlySet<string> = new Set(['submitted', 'revoked', 'expired'])\n\nexport type BeginAnonymousArgs = {\n distribution: FormDistribution\n invitation?: FormInvitation\n locale?: string | null\n}\n\nexport type BeginAnonymousResult = {\n view: SubmissionViewModel\n invitation: FormInvitation\n accessToken: string\n expiresAt: string\n}\n\nexport type FormContextResult = {\n form: Form\n formVersion: FormVersion\n compiled: CompiledFormVersion\n}\n\nexport class DistributionService {\n private readonly emFactory: () => EntityManager\n private readonly submissionService: SubmissionService\n private readonly emitEvent: DistributionServiceOptions['emitEvent']\n private readonly now: () => Date\n private readonly logger: StructuredLogger\n\n constructor(options: DistributionServiceOptions) {\n this.emFactory = options.emFactory\n this.submissionService = options.submissionService\n this.emitEvent = options.emitEvent\n this.now = options.now ?? (() => new Date())\n this.logger = options.logger ?? noopLogger\n }\n\n // --------------------------------------------------------------------------\n // Embed eligibility (spec D5 / R-RS-2)\n // --------------------------------------------------------------------------\n\n /**\n * True only when a distribution may be framed on a third-party website:\n * an active OPEN distribution that does not require customer auth and has\n * opted in via a valid, non-empty `settings.embed` allowlist. There is no\n * session inside a third-party iframe, so personal / auth-required / disabled\n * distributions are never embeddable.\n */\n isEmbeddable(distribution: FormDistribution): boolean {\n if (distribution.mode !== 'open') return false\n if (distribution.status !== 'active') return false\n if (distribution.requireCustomerAuth) return false\n return isEmbedEnabled(readEmbedSettings(distribution.settings))\n }\n\n /**\n * Resolves the `frame-ancestors` CSP directive for the `/embed/:slug` host\n * page WITHOUT enforcing the runtime availability window \u2014 framing is\n * permitted for any config-embeddable distribution (open + active + !auth +\n * opted-in allowlist) so the iframe can still load and render a graceful\n * \"unavailable\" state for a capped / out-of-window form. Fails closed\n * (`frame-ancestors 'none'`) for unknown or non-embeddable slugs.\n */\n async getEmbedPolicyBySlug(\n slug: string,\n ): Promise<{ frameAncestors: string; embeddable: boolean }> {\n const em = this.emFactory()\n const distribution = await em.findOne(FormDistribution, {\n publicSlug: slug,\n deletedAt: null,\n })\n if (!distribution) return { frameAncestors: \"frame-ancestors 'none'\", embeddable: false }\n const embeddable = this.isEmbeddable(distribution)\n const frameAncestors = embeddable\n ? buildFrameAncestorsCsp(readEmbedSettings(distribution.settings))\n : \"frame-ancestors 'none'\"\n return { frameAncestors, embeddable }\n }\n\n // --------------------------------------------------------------------------\n // Resolve \u2014 open link (global slug lookup)\n // --------------------------------------------------------------------------\n\n async resolveBySlug(slug: string): Promise<{ distribution: FormDistribution }> {\n const em = this.emFactory()\n const distribution = await em.findOne(FormDistribution, {\n publicSlug: slug,\n deletedAt: null,\n })\n if (!distribution) {\n throw new DistributionServiceError('NOT_FOUND', 'Distribution not found.', 404)\n }\n this.assertDistributionAvailable(distribution)\n return { distribution }\n }\n\n // --------------------------------------------------------------------------\n // Resolve \u2014 personal invitation (global token-hash lookup)\n // --------------------------------------------------------------------------\n\n async resolveByToken(rawToken: string): Promise<{ invitation: FormInvitation; distribution: FormDistribution }> {\n const em = this.emFactory()\n const tokenHash = hashInvitationToken(rawToken)\n // Locate the row first (no scope) to derive org/tenant, then re-read with\n // decryption scoped to that row so recipient PII is available to the caller.\n const located = await em.findOne(FormInvitation, {\n tokenHash,\n deletedAt: null,\n })\n if (!located) {\n throw new DistributionServiceError('NOT_FOUND', 'Invitation not found.', 404)\n }\n const invitation = await findOneWithDecryption(\n em,\n FormInvitation,\n { id: located.id, organizationId: located.organizationId, tenantId: located.tenantId, deletedAt: null },\n undefined,\n { organizationId: located.organizationId, tenantId: located.tenantId },\n )\n if (!invitation) {\n throw new DistributionServiceError('NOT_FOUND', 'Invitation not found.', 404)\n }\n\n this.assertInvitationUsable(invitation)\n\n const distribution = await em.findOne(FormDistribution, {\n id: invitation.distributionId,\n organizationId: invitation.organizationId,\n tenantId: invitation.tenantId,\n deletedAt: null,\n })\n if (!distribution) {\n throw new DistributionServiceError('NOT_FOUND', 'Distribution not found.', 404)\n }\n this.assertDistributionAvailable(distribution)\n\n // Mark opened on first resolve from a pre-fill status.\n if (invitation.status === 'pending' || invitation.status === 'sent') {\n invitation.status = 'opened'\n invitation.openedAt = this.now()\n await em.flush()\n await this.safeEmit('forms.invitation.opened', { invitationId: invitation.id })\n }\n\n return { invitation, distribution }\n }\n\n // --------------------------------------------------------------------------\n // Form context \u2014 resolve the served form version for a distribution\n // --------------------------------------------------------------------------\n\n async getFormContext(args: { distribution: FormDistribution }): Promise<FormContextResult> {\n const { distribution } = args\n const em = this.emFactory()\n const form = await em.findOne(Form, {\n id: distribution.formId,\n organizationId: distribution.organizationId,\n tenantId: distribution.tenantId,\n deletedAt: null,\n })\n if (!form) {\n throw new DistributionServiceError('NOT_FOUND', 'Form not found.', 404)\n }\n if (distribution.pinnedVersionId) {\n return this.submissionService.getPinnedFormVersionByKey({\n organizationId: distribution.organizationId,\n tenantId: distribution.tenantId,\n formKey: form.key,\n pinnedVersionId: distribution.pinnedVersionId,\n })\n }\n return this.submissionService.getActiveFormVersionByKey({\n organizationId: distribution.organizationId,\n tenantId: distribution.tenantId,\n formKey: form.key,\n })\n }\n\n // --------------------------------------------------------------------------\n // Begin anonymous \u2014 mint/link invitation, start submission, sign token\n // --------------------------------------------------------------------------\n\n async beginAnonymous(args: BeginAnonymousArgs): Promise<BeginAnonymousResult> {\n const { distribution } = args\n this.assertDistributionAvailable(distribution)\n\n const em = this.emFactory()\n const form = await em.findOne(Form, {\n id: distribution.formId,\n organizationId: distribution.organizationId,\n tenantId: distribution.tenantId,\n deletedAt: null,\n })\n if (!form) {\n throw new DistributionServiceError('NOT_FOUND', 'Form not found.', 404)\n }\n\n let invitation: FormInvitation\n if (args.invitation) {\n invitation = args.invitation\n this.assertInvitationUsable(invitation)\n // Resume: invitation already has a submission \u2014 return the existing one.\n if (invitation.submissionId) {\n return this.resumeExisting(distribution, form.key, invitation)\n }\n } else {\n const now = this.now()\n invitation = em.create(FormInvitation, {\n distributionId: distribution.id,\n organizationId: distribution.organizationId,\n tenantId: distribution.tenantId,\n recipientEmail: null,\n recipientName: null,\n recipientRef: null,\n role: null,\n tokenHash: null,\n status: 'started',\n submissionId: null,\n locale: args.locale ?? distribution.defaultLocale ?? null,\n sendCount: 0,\n createdBy: null,\n createdAt: now,\n updatedAt: now,\n })\n em.persist(invitation)\n await em.flush()\n }\n\n const view = await this.submissionService.start({\n organizationId: distribution.organizationId,\n tenantId: distribution.tenantId,\n formKey: form.key,\n subjectType: 'forms_invitation',\n subjectId: invitation.id,\n startedBy: invitation.id,\n initialRole: invitation.role ?? null,\n pinnedVersionId: distribution.pinnedVersionId ?? undefined,\n })\n\n const now = this.now()\n invitation.submissionId = view.submission.id\n invitation.status = 'started'\n invitation.startedAt = now\n if (!invitation.locale) invitation.locale = args.locale ?? distribution.defaultLocale ?? null\n await em.flush()\n\n const role = view.actors[0]?.role ?? invitation.role ?? null\n return this.issueAccess(view, invitation, role)\n }\n\n // --------------------------------------------------------------------------\n // Reserve a response slot (atomic cap enforcement \u2014 R-2d-5)\n // --------------------------------------------------------------------------\n\n async reserveResponseSlot(args: { distributionId: string; organizationId: string; tenantId: string }): Promise<void> {\n const em = this.emFactory()\n await em.transactional(async (trx) => {\n const distribution = await trx.findOne(\n FormDistribution,\n {\n id: args.distributionId,\n organizationId: args.organizationId,\n tenantId: args.tenantId,\n deletedAt: null,\n },\n { lockMode: LockMode.PESSIMISTIC_WRITE },\n )\n if (!distribution) {\n throw new DistributionServiceError('NOT_FOUND', 'Distribution not found.', 404)\n }\n this.assertDistributionAvailable(distribution)\n distribution.responseCount += 1\n await trx.flush()\n })\n }\n\n // --------------------------------------------------------------------------\n // Mark invitation submitted\n // --------------------------------------------------------------------------\n\n async markInvitationSubmitted(args: {\n invitationId: string\n organizationId: string\n tenantId: string\n submissionId: string\n }): Promise<void> {\n const em = this.emFactory()\n const invitation = await em.findOne(FormInvitation, {\n id: args.invitationId,\n organizationId: args.organizationId,\n tenantId: args.tenantId,\n deletedAt: null,\n })\n if (!invitation) {\n throw new DistributionServiceError('NOT_FOUND', 'Invitation not found.', 404)\n }\n invitation.status = 'submitted'\n invitation.submittedAt = this.now()\n await em.flush()\n await this.safeEmit('forms.invitation.submitted', {\n invitationId: invitation.id,\n submissionId: args.submissionId,\n })\n }\n\n // --------------------------------------------------------------------------\n // Slide the access-token expiry (used by the autosave route)\n // --------------------------------------------------------------------------\n\n refreshAccessToken(submissionId: string, invitationId: string, role: string | null): string {\n return signAccessToken({\n submissionId,\n invitationId,\n role,\n expiresAtSeconds: this.expiresAtSeconds(),\n })\n }\n\n // --------------------------------------------------------------------------\n // Helpers\n // --------------------------------------------------------------------------\n\n private async resumeExisting(\n distribution: FormDistribution,\n formKey: string,\n invitation: FormInvitation,\n ): Promise<BeginAnonymousResult> {\n const view = await this.submissionService.getCurrent({\n organizationId: invitation.organizationId,\n tenantId: invitation.tenantId,\n submissionId: invitation.submissionId as string,\n viewerRole: invitation.role ?? null,\n viewerUserId: invitation.id,\n })\n void distribution\n void formKey\n const role = view.actors.find((actor) => actor.userId === invitation.id)?.role\n ?? invitation.role\n ?? view.actors[0]?.role\n ?? null\n return this.issueAccess(view, invitation, role)\n }\n\n private issueAccess(\n view: SubmissionViewModel,\n invitation: FormInvitation,\n role: string | null,\n ): BeginAnonymousResult {\n const expiresAtSeconds = this.expiresAtSeconds()\n const accessToken = signAccessToken({\n submissionId: view.submission.id,\n invitationId: invitation.id,\n role,\n expiresAtSeconds,\n })\n return {\n view,\n invitation,\n accessToken,\n expiresAt: new Date(expiresAtSeconds * 1000).toISOString(),\n }\n }\n\n private expiresAtSeconds(): number {\n return Math.floor(this.now().getTime() / 1000) + getAccessTokenTtlSeconds()\n }\n\n private assertDistributionAvailable(distribution: FormDistribution): void {\n if (distribution.status !== 'active') {\n throw new DistributionServiceError('GONE', 'Distribution is not active.', 410, {\n status: distribution.status,\n })\n }\n const now = this.now().getTime()\n if (distribution.opensAt && now < distribution.opensAt.getTime()) {\n throw new DistributionServiceError('GONE', 'Distribution is not open yet.', 410)\n }\n if (distribution.closesAt && now > distribution.closesAt.getTime()) {\n throw new DistributionServiceError('GONE', 'Distribution is closed.', 410)\n }\n if (distribution.maxResponses != null && distribution.responseCount >= distribution.maxResponses) {\n throw new DistributionServiceError('GONE', 'Distribution response cap reached.', 410)\n }\n }\n\n private assertInvitationUsable(invitation: FormInvitation): void {\n if (INVITATION_TERMINAL_STATUSES.has(invitation.status)) {\n throw new DistributionServiceError('GONE', `Invitation is ${invitation.status}.`, 410, {\n status: invitation.status,\n })\n }\n if (invitation.expiresAt && this.now().getTime() > invitation.expiresAt.getTime()) {\n throw new DistributionServiceError('GONE', 'Invitation is expired.', 410)\n }\n }\n\n private async safeEmit<K extends keyof DistributionEvents>(\n eventId: K,\n payload: DistributionEvents[K],\n ): Promise<void> {\n try {\n await this.emitEvent(eventId, payload)\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown emit error'\n this.logger.warn(\n { event: 'forms.distribution.event_emit_failed', eventId, message },\n 'forms distribution event emission failed',\n )\n }\n }\n}\n\n/** Brand check for DistributionServiceError \u2014 use instead of `instanceof`. */\nexport function isDistributionServiceError(error: unknown): error is DistributionServiceError {\n return typeof error === 'object' && error !== null && (DISTRIBUTION_SERVICE_ERROR_MARKER in error)\n}\n"],
|
|
5
|
-
"mappings": "AAAA;AAmBA,SAAS,gBAAgB;AAGzB,SAAS,6BAA6B;AACtC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AAIP,SAAS,wBAAwB,gBAAgB,yBAAyB;AAE1E;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAcP,MAAM,oCAAoC,uBAAO,IAAI,8CAA8C;AAE5F,MAAM,kCAAiC,YAClC,wCADkC,IAAM;AAAA,EAMlD,YACE,MACA,SACA,YACA,SACA;AACA,UAAM,OAAO;AAXf,SAAU,MAAqC;AAY7C,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,SAAK,aAAa;AAClB,SAAK,UAAU;AAAA,EACjB;AACF;AAgBA,MAAM,aAA+B;AAAA,EACnC,MAAM,MAAM;AAAA,EAAC;AAAA,EACb,MAAM,MAAM;AAAA,EAAC;AAAA,EACb,OAAO,MAAM;AAAA,EAAC;AAChB;
|
|
4
|
+
"sourcesContent": ["/**\n * Forms DistributionService \u2014 phase 2d.\n *\n * Orchestrates the distribution / invitation layer that puts a published form\n * in front of an unauthenticated participant and bootstraps an anonymous\n * submission by delegating to the unchanged `SubmissionService`.\n *\n * Invariants:\n * - Every query is tenant-scoped (`organization_id` + `tenant_id`) EXCEPT the\n * two deliberately-global lookups \u2014 by random `public_slug` and by\n * `token_hash` \u2014 both high-entropy and globally unique in practice. After a\n * global lookup the org/tenant scope is taken from the found row and used\n * for every subsequent query.\n * - Recipient PII (`recipient_email` / `recipient_name`) is encrypted via the\n * GLOBAL pipeline; reads that need the plaintext use `findOneWithDecryption`.\n * - The submission access token never carries org/tenant \u2014 those are\n * re-derived from the persisted submission downstream (see runtime-principal).\n */\n\nimport { LockMode } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { z } from 'zod'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport {\n Form,\n FormDistribution,\n FormInvitation,\n FormVersion,\n} from '../data/entities'\nimport type { CompiledFormVersion } from './form-version-compiler'\nimport type { SubmissionService, SubmissionViewModel } from './submission-service'\nimport type { StructuredLogger } from '../lib/log-redaction'\nimport { buildFrameAncestorsCsp, isEmbedEnabled, readEmbedSettings } from '../lib/embed-frame-policy'\nimport { formsEventPayloadSchemas } from '../events-payloads'\nimport {\n getAccessTokenTtlSeconds,\n hashInvitationToken,\n signAccessToken,\n} from './distribution-token'\n\nexport type DistributionServiceErrorCode =\n | 'NOT_FOUND'\n | 'GONE'\n | 'INVALID_STATUS'\n\n/**\n * `Symbol.for` so the brand survives module duplication across bundle\n * boundaries. `instanceof` does NOT: Next can load this module in more than\n * one chunk, and once a second copy of the class exists an error thrown\n * through one copy fails `instanceof` against the other \u2014 silently\n * downgrading a mapped domain error to a 500. Mirrors `CrudHttpError`.\n */\nconst DISTRIBUTION_SERVICE_ERROR_MARKER = Symbol.for('@open-mercato/forms/DistributionServiceError')\n\nexport class DistributionServiceError extends Error {\n readonly [DISTRIBUTION_SERVICE_ERROR_MARKER] = true\n readonly code: DistributionServiceErrorCode\n readonly httpStatus: number\n readonly details?: Record<string, unknown>\n\n constructor(\n code: DistributionServiceErrorCode,\n message: string,\n httpStatus: number,\n details?: Record<string, unknown>,\n ) {\n super(message)\n this.name = 'DistributionServiceError'\n this.code = code\n this.httpStatus = httpStatus\n this.details = details\n }\n}\n\ntype DistributionEvents = {\n 'forms.invitation.opened': z.infer<typeof formsEventPayloadSchemas['forms.invitation.opened']>\n 'forms.invitation.submitted': z.infer<typeof formsEventPayloadSchemas['forms.invitation.submitted']>\n}\n\nexport type DistributionServiceOptions = {\n emFactory: () => EntityManager\n submissionService: SubmissionService\n /** Pluggable, schema-validated event emitter \u2014 wired in di.ts. */\n emitEvent: <K extends keyof DistributionEvents>(eventId: K, payload: DistributionEvents[K]) => Promise<void> | void\n now?: () => Date\n logger?: StructuredLogger\n}\n\nconst noopLogger: StructuredLogger = {\n info: () => {},\n warn: () => {},\n error: () => {},\n}\n\nexport const INVITATION_ACCESS_CLOSED_STATUSES: ReadonlySet<string> = new Set(['revoked', 'expired'])\n\nconst INVITATION_TERMINAL_STATUSES: ReadonlySet<string> = new Set(['submitted', ...INVITATION_ACCESS_CLOSED_STATUSES])\n\nexport type BeginAnonymousArgs = {\n distribution: FormDistribution\n invitation?: FormInvitation\n locale?: string | null\n}\n\nexport type BeginAnonymousResult = {\n view: SubmissionViewModel\n invitation: FormInvitation\n accessToken: string\n expiresAt: string\n}\n\nexport type FormContextResult = {\n form: Form\n formVersion: FormVersion\n compiled: CompiledFormVersion\n}\n\nexport class DistributionService {\n private readonly emFactory: () => EntityManager\n private readonly submissionService: SubmissionService\n private readonly emitEvent: DistributionServiceOptions['emitEvent']\n private readonly now: () => Date\n private readonly logger: StructuredLogger\n\n constructor(options: DistributionServiceOptions) {\n this.emFactory = options.emFactory\n this.submissionService = options.submissionService\n this.emitEvent = options.emitEvent\n this.now = options.now ?? (() => new Date())\n this.logger = options.logger ?? noopLogger\n }\n\n // --------------------------------------------------------------------------\n // Embed eligibility (spec D5 / R-RS-2)\n // --------------------------------------------------------------------------\n\n /**\n * True only when a distribution may be framed on a third-party website:\n * an active OPEN distribution that does not require customer auth and has\n * opted in via a valid, non-empty `settings.embed` allowlist. There is no\n * session inside a third-party iframe, so personal / auth-required / disabled\n * distributions are never embeddable.\n */\n isEmbeddable(distribution: FormDistribution): boolean {\n if (distribution.mode !== 'open') return false\n if (distribution.status !== 'active') return false\n if (distribution.requireCustomerAuth) return false\n return isEmbedEnabled(readEmbedSettings(distribution.settings))\n }\n\n /**\n * Resolves the `frame-ancestors` CSP directive for the `/embed/:slug` host\n * page WITHOUT enforcing the runtime availability window \u2014 framing is\n * permitted for any config-embeddable distribution (open + active + !auth +\n * opted-in allowlist) so the iframe can still load and render a graceful\n * \"unavailable\" state for a capped / out-of-window form. Fails closed\n * (`frame-ancestors 'none'`) for unknown or non-embeddable slugs.\n */\n async getEmbedPolicyBySlug(\n slug: string,\n ): Promise<{ frameAncestors: string; embeddable: boolean }> {\n const em = this.emFactory()\n const distribution = await em.findOne(FormDistribution, {\n publicSlug: slug,\n deletedAt: null,\n })\n if (!distribution) return { frameAncestors: \"frame-ancestors 'none'\", embeddable: false }\n const embeddable = this.isEmbeddable(distribution)\n const frameAncestors = embeddable\n ? buildFrameAncestorsCsp(readEmbedSettings(distribution.settings))\n : \"frame-ancestors 'none'\"\n return { frameAncestors, embeddable }\n }\n\n // --------------------------------------------------------------------------\n // Resolve \u2014 open link (global slug lookup)\n // --------------------------------------------------------------------------\n\n async resolveBySlug(slug: string): Promise<{ distribution: FormDistribution }> {\n const em = this.emFactory()\n const distribution = await em.findOne(FormDistribution, {\n publicSlug: slug,\n deletedAt: null,\n })\n if (!distribution) {\n throw new DistributionServiceError('NOT_FOUND', 'Distribution not found.', 404)\n }\n this.assertDistributionAvailable(distribution)\n return { distribution }\n }\n\n // --------------------------------------------------------------------------\n // Resolve \u2014 personal invitation (global token-hash lookup)\n // --------------------------------------------------------------------------\n\n async resolveByToken(rawToken: string): Promise<{ invitation: FormInvitation; distribution: FormDistribution }> {\n const em = this.emFactory()\n const tokenHash = hashInvitationToken(rawToken)\n // Locate the row first (no scope) to derive org/tenant, then re-read with\n // decryption scoped to that row so recipient PII is available to the caller.\n const located = await em.findOne(FormInvitation, {\n tokenHash,\n deletedAt: null,\n })\n if (!located) {\n throw new DistributionServiceError('NOT_FOUND', 'Invitation not found.', 404)\n }\n const invitation = await findOneWithDecryption(\n em,\n FormInvitation,\n { id: located.id, organizationId: located.organizationId, tenantId: located.tenantId, deletedAt: null },\n undefined,\n { organizationId: located.organizationId, tenantId: located.tenantId },\n )\n if (!invitation) {\n throw new DistributionServiceError('NOT_FOUND', 'Invitation not found.', 404)\n }\n\n this.assertInvitationUsable(invitation)\n\n const distribution = await em.findOne(FormDistribution, {\n id: invitation.distributionId,\n organizationId: invitation.organizationId,\n tenantId: invitation.tenantId,\n deletedAt: null,\n })\n if (!distribution) {\n throw new DistributionServiceError('NOT_FOUND', 'Distribution not found.', 404)\n }\n this.assertDistributionAvailable(distribution)\n\n // Mark opened on first resolve from a pre-fill status.\n if (invitation.status === 'pending' || invitation.status === 'sent') {\n invitation.status = 'opened'\n invitation.openedAt = this.now()\n await em.flush()\n await this.safeEmit('forms.invitation.opened', { invitationId: invitation.id })\n }\n\n return { invitation, distribution }\n }\n\n // --------------------------------------------------------------------------\n // Form context \u2014 resolve the served form version for a distribution\n // --------------------------------------------------------------------------\n\n async getFormContext(args: { distribution: FormDistribution }): Promise<FormContextResult> {\n const { distribution } = args\n const em = this.emFactory()\n const form = await em.findOne(Form, {\n id: distribution.formId,\n organizationId: distribution.organizationId,\n tenantId: distribution.tenantId,\n deletedAt: null,\n })\n if (!form) {\n throw new DistributionServiceError('NOT_FOUND', 'Form not found.', 404)\n }\n if (distribution.pinnedVersionId) {\n return this.submissionService.getPinnedFormVersionByKey({\n organizationId: distribution.organizationId,\n tenantId: distribution.tenantId,\n formKey: form.key,\n pinnedVersionId: distribution.pinnedVersionId,\n })\n }\n return this.submissionService.getActiveFormVersionByKey({\n organizationId: distribution.organizationId,\n tenantId: distribution.tenantId,\n formKey: form.key,\n })\n }\n\n // --------------------------------------------------------------------------\n // Begin anonymous \u2014 mint/link invitation, start submission, sign token\n // --------------------------------------------------------------------------\n\n async beginAnonymous(args: BeginAnonymousArgs): Promise<BeginAnonymousResult> {\n const { distribution } = args\n this.assertDistributionAvailable(distribution)\n\n const em = this.emFactory()\n const form = await em.findOne(Form, {\n id: distribution.formId,\n organizationId: distribution.organizationId,\n tenantId: distribution.tenantId,\n deletedAt: null,\n })\n if (!form) {\n throw new DistributionServiceError('NOT_FOUND', 'Form not found.', 404)\n }\n\n let invitation: FormInvitation\n if (args.invitation) {\n invitation = args.invitation\n this.assertInvitationUsable(invitation)\n // Resume: invitation already has a submission \u2014 return the existing one.\n if (invitation.submissionId) {\n return this.resumeExisting(distribution, form.key, invitation)\n }\n } else {\n const now = this.now()\n invitation = em.create(FormInvitation, {\n distributionId: distribution.id,\n organizationId: distribution.organizationId,\n tenantId: distribution.tenantId,\n recipientEmail: null,\n recipientName: null,\n recipientRef: null,\n role: null,\n tokenHash: null,\n status: 'started',\n submissionId: null,\n locale: args.locale ?? distribution.defaultLocale ?? null,\n sendCount: 0,\n createdBy: null,\n createdAt: now,\n updatedAt: now,\n })\n em.persist(invitation)\n await em.flush()\n }\n\n const view = await this.submissionService.start({\n organizationId: distribution.organizationId,\n tenantId: distribution.tenantId,\n formKey: form.key,\n subjectType: 'forms_invitation',\n subjectId: invitation.id,\n startedBy: invitation.id,\n initialRole: invitation.role ?? null,\n pinnedVersionId: distribution.pinnedVersionId ?? undefined,\n })\n\n const now = this.now()\n invitation.submissionId = view.submission.id\n invitation.status = 'started'\n invitation.startedAt = now\n if (!invitation.locale) invitation.locale = args.locale ?? distribution.defaultLocale ?? null\n await em.flush()\n\n const role = view.actors[0]?.role ?? invitation.role ?? null\n return this.issueAccess(view, invitation, role)\n }\n\n // --------------------------------------------------------------------------\n // Reserve a response slot (atomic cap enforcement \u2014 R-2d-5)\n // --------------------------------------------------------------------------\n\n async reserveResponseSlot(args: { distributionId: string; organizationId: string; tenantId: string }): Promise<void> {\n const em = this.emFactory()\n await em.transactional(async (trx) => {\n const distribution = await trx.findOne(\n FormDistribution,\n {\n id: args.distributionId,\n organizationId: args.organizationId,\n tenantId: args.tenantId,\n deletedAt: null,\n },\n { lockMode: LockMode.PESSIMISTIC_WRITE },\n )\n if (!distribution) {\n throw new DistributionServiceError('NOT_FOUND', 'Distribution not found.', 404)\n }\n this.assertDistributionAvailable(distribution)\n distribution.responseCount += 1\n await trx.flush()\n })\n }\n\n // --------------------------------------------------------------------------\n // Mark invitation submitted\n // --------------------------------------------------------------------------\n\n async markInvitationSubmitted(args: {\n invitationId: string\n organizationId: string\n tenantId: string\n submissionId: string\n }): Promise<void> {\n const em = this.emFactory()\n const marked = await em.transactional(async (trx) => {\n const invitation = await trx.findOne(\n FormInvitation,\n {\n id: args.invitationId,\n organizationId: args.organizationId,\n tenantId: args.tenantId,\n deletedAt: null,\n },\n { lockMode: LockMode.PESSIMISTIC_WRITE },\n )\n if (!invitation) {\n throw new DistributionServiceError('NOT_FOUND', 'Invitation not found.', 404)\n }\n if (INVITATION_ACCESS_CLOSED_STATUSES.has(invitation.status)) {\n this.logger.warn(\n {\n event: 'forms.distribution.invitation_submit_skipped',\n invitationId: invitation.id,\n submissionId: args.submissionId,\n status: invitation.status,\n },\n 'Invitation access closed before submit; status left unchanged.',\n )\n return false\n }\n invitation.status = 'submitted'\n invitation.submittedAt = this.now()\n await trx.flush()\n return true\n })\n if (!marked) return\n await this.safeEmit('forms.invitation.submitted', {\n invitationId: args.invitationId,\n submissionId: args.submissionId,\n })\n }\n\n // --------------------------------------------------------------------------\n // Slide the access-token expiry (used by the autosave route)\n // --------------------------------------------------------------------------\n\n refreshAccessToken(submissionId: string, invitationId: string, role: string | null): string {\n return signAccessToken({\n submissionId,\n invitationId,\n role,\n expiresAtSeconds: this.expiresAtSeconds(),\n })\n }\n\n // --------------------------------------------------------------------------\n // Helpers\n // --------------------------------------------------------------------------\n\n private async resumeExisting(\n distribution: FormDistribution,\n formKey: string,\n invitation: FormInvitation,\n ): Promise<BeginAnonymousResult> {\n const view = await this.submissionService.getCurrent({\n organizationId: invitation.organizationId,\n tenantId: invitation.tenantId,\n submissionId: invitation.submissionId as string,\n viewerRole: invitation.role ?? null,\n viewerUserId: invitation.id,\n })\n void distribution\n void formKey\n const role = view.actors.find((actor) => actor.userId === invitation.id)?.role\n ?? invitation.role\n ?? view.actors[0]?.role\n ?? null\n return this.issueAccess(view, invitation, role)\n }\n\n private issueAccess(\n view: SubmissionViewModel,\n invitation: FormInvitation,\n role: string | null,\n ): BeginAnonymousResult {\n const expiresAtSeconds = this.expiresAtSeconds()\n const accessToken = signAccessToken({\n submissionId: view.submission.id,\n invitationId: invitation.id,\n role,\n expiresAtSeconds,\n })\n return {\n view,\n invitation,\n accessToken,\n expiresAt: new Date(expiresAtSeconds * 1000).toISOString(),\n }\n }\n\n private expiresAtSeconds(): number {\n return Math.floor(this.now().getTime() / 1000) + getAccessTokenTtlSeconds()\n }\n\n private assertDistributionAvailable(distribution: FormDistribution): void {\n if (distribution.status !== 'active') {\n throw new DistributionServiceError('GONE', 'Distribution is not active.', 410, {\n status: distribution.status,\n })\n }\n const now = this.now().getTime()\n if (distribution.opensAt && now < distribution.opensAt.getTime()) {\n throw new DistributionServiceError('GONE', 'Distribution is not open yet.', 410)\n }\n if (distribution.closesAt && now > distribution.closesAt.getTime()) {\n throw new DistributionServiceError('GONE', 'Distribution is closed.', 410)\n }\n if (distribution.maxResponses != null && distribution.responseCount >= distribution.maxResponses) {\n throw new DistributionServiceError('GONE', 'Distribution response cap reached.', 410)\n }\n }\n\n private assertInvitationUsable(invitation: FormInvitation): void {\n if (INVITATION_TERMINAL_STATUSES.has(invitation.status)) {\n throw new DistributionServiceError('GONE', `Invitation is ${invitation.status}.`, 410, {\n status: invitation.status,\n })\n }\n if (invitation.expiresAt && this.now().getTime() > invitation.expiresAt.getTime()) {\n throw new DistributionServiceError('GONE', 'Invitation is expired.', 410)\n }\n }\n\n private async safeEmit<K extends keyof DistributionEvents>(\n eventId: K,\n payload: DistributionEvents[K],\n ): Promise<void> {\n try {\n await this.emitEvent(eventId, payload)\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown emit error'\n this.logger.warn(\n { event: 'forms.distribution.event_emit_failed', eventId, message },\n 'forms distribution event emission failed',\n )\n }\n }\n}\n\n/** Brand check for DistributionServiceError \u2014 use instead of `instanceof`. */\nexport function isDistributionServiceError(error: unknown): error is DistributionServiceError {\n return typeof error === 'object' && error !== null && (DISTRIBUTION_SERVICE_ERROR_MARKER in error)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["now"]
|
|
7
7
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
sliceByVisibility
|
|
14
14
|
} from "./visibility-resolver.js";
|
|
15
15
|
import { buildTamperingMarker } from "../lib/log-redaction.js";
|
|
16
|
+
import { isPortalSubmitAllowed } from "../lib/portal-subject-policy.js";
|
|
16
17
|
import { validateFieldValue } from "./field-validation-service.js";
|
|
17
18
|
const DEFAULT_AUTOSAVE_INTERVAL_MS = (() => {
|
|
18
19
|
const raw = process.env.FORMS_AUTOSAVE_INTERVAL_MS;
|
|
@@ -459,6 +460,9 @@ class SubmissionService {
|
|
|
459
460
|
if (!submission) {
|
|
460
461
|
throw new SubmissionServiceError("NOT_FOUND", "Submission not found.", 404);
|
|
461
462
|
}
|
|
463
|
+
if (args.submittedByPortalCustomer && !isPortalSubmitAllowed(args.submittedBy, submission)) {
|
|
464
|
+
throw new SubmissionServiceError("SUBJECT_FORBIDDEN", "Submission subject is not the caller's own customer subject.", 403);
|
|
465
|
+
}
|
|
462
466
|
if (submission.status === "submitted") {
|
|
463
467
|
throw new SubmissionServiceError("INVALID_STATUS", "Submission already submitted.", 422);
|
|
464
468
|
}
|