@jini-ai/cms 0.5.3 → 0.5.4
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/CHANGELOG.md +4 -0
- package/dist/forms/delete-submission.d.ts +38 -0
- package/dist/forms/delete-submission.js +25 -0
- package/dist/forms/duplicate-slug.d.ts +81 -0
- package/dist/forms/duplicate-slug.js +115 -0
- package/dist/forms/errors.d.ts +53 -0
- package/dist/forms/errors.js +25 -0
- package/dist/forms/express/handler.d.ts +59 -0
- package/dist/forms/express/handler.js +217 -0
- package/dist/forms/express/index.d.ts +2 -0
- package/dist/forms/express/index.js +2 -0
- package/dist/forms/fields-json.d.ts +14 -0
- package/dist/forms/fields-json.js +11 -0
- package/dist/forms/forms.d.ts +59 -0
- package/dist/forms/forms.js +213 -0
- package/dist/forms/html/authoring-adapter.d.ts +34 -0
- package/dist/forms/html/authoring-adapter.js +48 -0
- package/dist/forms/html/html-authoring.d.ts +12 -0
- package/dist/forms/html/html-authoring.js +133 -0
- package/dist/forms/html/html-submission-adapter.d.ts +10 -0
- package/dist/forms/html/html-submission-adapter.js +15 -0
- package/dist/forms/html/index.d.ts +4 -0
- package/dist/forms/html/index.js +4 -0
- package/dist/forms/html-render.d.ts +33 -0
- package/dist/forms/html-render.js +31 -0
- package/dist/forms/index.d.ts +17 -0
- package/dist/forms/index.js +17 -0
- package/dist/forms/notify-subscriber.d.ts +32 -0
- package/dist/forms/notify-subscriber.js +60 -0
- package/dist/forms/ports.d.ts +168 -0
- package/dist/forms/ports.js +2 -0
- package/dist/forms/rate-limit-key.d.ts +8 -0
- package/dist/forms/rate-limit-key.js +7 -0
- package/dist/forms/repo.memory.d.ts +120 -0
- package/dist/forms/repo.memory.js +188 -0
- package/dist/forms/sql/index.d.ts +5 -0
- package/dist/forms/sql/index.js +5 -0
- package/dist/forms/sql/repo.d.ts +94 -0
- package/dist/forms/sql/repo.js +175 -0
- package/dist/forms/sql/repo.rows.d.ts +33 -0
- package/dist/forms/sql/repo.rows.js +64 -0
- package/dist/forms/sql/submission-ip-retention-repo.d.ts +9 -0
- package/dist/forms/sql/submission-ip-retention-repo.js +40 -0
- package/dist/forms/sql/tables.d.ts +32 -0
- package/dist/forms/sql/tables.js +2 -0
- package/dist/forms/submission-attempts.d.ts +70 -0
- package/dist/forms/submission-attempts.js +70 -0
- package/dist/forms/submission-email-validation.d.ts +8 -0
- package/dist/forms/submission-email-validation.js +34 -0
- package/dist/forms/submission-ip-retention.d.ts +30 -0
- package/dist/forms/submission-ip-retention.js +27 -0
- package/dist/forms/submit-service.d.ts +78 -0
- package/dist/forms/submit-service.js +109 -0
- package/dist/forms/types.d.ts +49 -0
- package/dist/forms/types.js +2 -0
- package/dist/forms/write-service.d.ts +83 -0
- package/dist/forms/write-service.js +271 -0
- package/package.json +3 -4
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Submission metadata retention, independent of databases and host scheduling.
|
|
3
|
+
* Answers and submission identity are never deleted by this maintenance operation.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Owner decision 2026-10-04: retain form submitter IPs for 90 days for abuse investigation,
|
|
7
|
+
* then remove them automatically. Matches the privacy wording: "IP addresses on form
|
|
8
|
+
* submissions are deleted after 90 days"; this is not expiry of the submission itself.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SUBMISSION_IP_RETENTION_DAYS = 90;
|
|
11
|
+
export interface SubmissionIpRetentionPort {
|
|
12
|
+
/**
|
|
13
|
+
* Clear only sourceIp to NULL for at most limit rows submitted at/before the ISO UTC cutoff.
|
|
14
|
+
* Include all workspaces and Trash rows. Perform one atomic guarded update; return actual
|
|
15
|
+
* affected rows, excluding already-NULL values. Concurrent passes must be idempotent.
|
|
16
|
+
*/
|
|
17
|
+
clearExpiredIps(required: {
|
|
18
|
+
submittedBeforeOrAt: string;
|
|
19
|
+
}, optional?: {
|
|
20
|
+
limit?: number;
|
|
21
|
+
}): Promise<number>;
|
|
22
|
+
}
|
|
23
|
+
/** A complete pass, using bounded updates and one fixed cutoff even across multiple batches. */
|
|
24
|
+
export declare function sweepExpiredSubmissionIps({ now, repo }: {
|
|
25
|
+
now: number;
|
|
26
|
+
repo: SubmissionIpRetentionPort;
|
|
27
|
+
}, { batchSize }?: {
|
|
28
|
+
batchSize?: number;
|
|
29
|
+
}): Promise<number>;
|
|
30
|
+
//# sourceMappingURL=submission-ip-retention.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Submission metadata retention, independent of databases and host scheduling.
|
|
3
|
+
* Answers and submission identity are never deleted by this maintenance operation.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Owner decision 2026-10-04: retain form submitter IPs for 90 days for abuse investigation,
|
|
7
|
+
* then remove them automatically. Matches the privacy wording: "IP addresses on form
|
|
8
|
+
* submissions are deleted after 90 days"; this is not expiry of the submission itself.
|
|
9
|
+
*/
|
|
10
|
+
export const SUBMISSION_IP_RETENTION_DAYS = 90;
|
|
11
|
+
/** A complete pass, using bounded updates and one fixed cutoff even across multiple batches. */
|
|
12
|
+
export async function sweepExpiredSubmissionIps({ now, repo }, { batchSize = 500 } = {}) {
|
|
13
|
+
const cutoff = now - SUBMISSION_IP_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
14
|
+
if (!Number.isFinite(now) || !Number.isFinite(new Date(cutoff).getTime()))
|
|
15
|
+
throw new RangeError("submission IP sweep needs a valid timestamp");
|
|
16
|
+
if (!Number.isSafeInteger(batchSize) || batchSize <= 0)
|
|
17
|
+
throw new RangeError("submission IP sweep batchSize must be a positive integer");
|
|
18
|
+
const submittedBeforeOrAt = new Date(cutoff).toISOString();
|
|
19
|
+
let total = 0;
|
|
20
|
+
let cleared;
|
|
21
|
+
do {
|
|
22
|
+
cleared = await repo.clearExpiredIps({ submittedBeforeOrAt }, { limit: batchSize });
|
|
23
|
+
total += cleared;
|
|
24
|
+
} while (cleared === batchSize);
|
|
25
|
+
return total;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=submission-ip-retention.js.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `submit-service.ts` — the sole public submission write path (SPEC-010 REQ-05..09/16,
|
|
3
|
+
* ADR-PIPE-010 C-008).
|
|
4
|
+
*
|
|
5
|
+
* Purpose:
|
|
6
|
+
* `submitForm` deliberately bypasses `executeCommand` (no actor/permission fits an anonymous
|
|
7
|
+
* visitor — ADR-PIPE-010 Pattern Evaluation); mirrors `routes/site/analytics-ingest.ts`'s
|
|
8
|
+
* `ingestHit` shape. Order: resolve slug -> honeypot check -> validate -> rate-limit -> persist ->
|
|
9
|
+
* enqueue `form.submission.received` -> return, WITHOUT awaiting outbox drainage.
|
|
10
|
+
*
|
|
11
|
+
* CRITICAL (INV-05/REQ-16/AC-24, tasks.md T023): the package kicks off the bound dispatcher without
|
|
12
|
+
* awaiting it. An inline route drain may await delivery when its subscribers are cheap; that is
|
|
13
|
+
* directly unacceptable here, where a subscriber calls `MailerPort.send()`. Copying that pattern
|
|
14
|
+
* verbatim would silently reintroduce the exact response-blocking bug REQ-16/INV-05 forbid.
|
|
15
|
+
* Do not await the dispatcher at the submission boundary — see T023/T049 for the standing
|
|
16
|
+
* Code Review gate on this exact line.
|
|
17
|
+
*
|
|
18
|
+
* Double submit (2026-10-05): a double-clicked Send posts the same form twice and the rendered form
|
|
19
|
+
* ships no script, so the dedupe is server-side — see `submission-attempts.ts` for the attempt token,
|
|
20
|
+
* its cached/static fallback and why it lives in process memory. Ids are random; the submission row
|
|
21
|
+
* and its event are written in one transaction, so a failed enqueue leaves nothing a retry could
|
|
22
|
+
* mistake for a finished submission.
|
|
23
|
+
*/
|
|
24
|
+
import type { Logger } from "@jini-ai/core/primitives";
|
|
25
|
+
import type { RateLimiterPort, FormOutboxPort, OutboxDispatcherPort, SubmissionTransactionPort } from "./ports.js";
|
|
26
|
+
import type { Clock, IdGenerator, UUID } from "@jini-ai/core/primitives";
|
|
27
|
+
import type { FormDefinitionRepoPort, FormSubmissionRepoPort } from "./ports.js";
|
|
28
|
+
import type { FormDefinitionViewPort } from "./ports.js";
|
|
29
|
+
import { type SubmissionAttempts } from "./submission-attempts.js";
|
|
30
|
+
export interface SubmitFormDeps {
|
|
31
|
+
definitionRepo: FormDefinitionRepoPort;
|
|
32
|
+
submissionRepo: FormSubmissionRepoPort;
|
|
33
|
+
outbox: FormOutboxPort;
|
|
34
|
+
dispatcher: OutboxDispatcherPort;
|
|
35
|
+
logger: Pick<Logger, 'warn'>;
|
|
36
|
+
clock: Clock;
|
|
37
|
+
idGen: IdGenerator;
|
|
38
|
+
rateLimiter: RateLimiterPort;
|
|
39
|
+
/** Omitted: the two writes run back to back with no rollback between them. */
|
|
40
|
+
transaction?: SubmissionTransactionPort;
|
|
41
|
+
}
|
|
42
|
+
export interface SubmitFormInput {
|
|
43
|
+
workspaceId: UUID;
|
|
44
|
+
slug: string;
|
|
45
|
+
body: Record<string, unknown>;
|
|
46
|
+
sourceIp: string;
|
|
47
|
+
}
|
|
48
|
+
export interface SubmitFormRequired {
|
|
49
|
+
deps: SubmitFormDeps;
|
|
50
|
+
input: SubmitFormInput;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Binds Tovu's outbox delivery to Jini's anonymous submission service. The package awaits the
|
|
54
|
+
* object-shaped limiter check and queues delivery without awaiting notifications.
|
|
55
|
+
*
|
|
56
|
+
* A repeat of an attempt accepted within the window (same token, or for a request without one the
|
|
57
|
+
* same IP and body) is answered exactly like the original — `accepted` — with no row, no event and
|
|
58
|
+
* no notification. It still passes the slug, honeypot, validation and rate checks first (and so
|
|
59
|
+
* consumes a rate-limit slot, as any request does): the attempt is claimed only where the package
|
|
60
|
+
* opens its persistence transaction, and a copy that must wait for the first copy waits there,
|
|
61
|
+
* outside any database transaction. The `_attempt` field is removed from the body before
|
|
62
|
+
* validation; it is never stored.
|
|
63
|
+
* @returns Accepted for a valid submission, a duplicate, or a honeypot discard; package errors propagate.
|
|
64
|
+
* @complexity O(f + k) package validation plus O(b log b) attempt hashing; fixed-count persistence
|
|
65
|
+
* effects, background dispatch.
|
|
66
|
+
* @example await submitForm({ deps, input: { workspaceId, slug, body, sourceIp } });
|
|
67
|
+
*/
|
|
68
|
+
export declare function submitForm(required: SubmitFormRequired, optional?: SubmitFormOptional): Promise<{
|
|
69
|
+
status: "accepted";
|
|
70
|
+
}>;
|
|
71
|
+
/** All extensions are opt-in: omitted options preserve the original package submission behavior. */
|
|
72
|
+
export interface SubmitFormOptional {
|
|
73
|
+
definitionView?: FormDefinitionViewPort;
|
|
74
|
+
validateEmails?: boolean;
|
|
75
|
+
dispatch?: OutboxDispatcherPort["dispatch"];
|
|
76
|
+
attempts?: SubmissionAttempts;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=submit-service.d.ts.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
|
|
2
|
+
/** Anonymous submission path: active lookup, honeypot, validation, rate check, persistence + event enqueue in one host transaction, then background dispatch. */
|
|
3
|
+
import { buildFormsRateLimitKey } from "./rate-limit-key.js";
|
|
4
|
+
import { FormDefinitionNotFoundError, FormRateLimitExceededError, FormSubmissionValidationError, } from "./errors.js";
|
|
5
|
+
import { isHoneypotTripped, validateSubmissionPayload } from "./forms.js";
|
|
6
|
+
import { assertSubmissionEmails } from "./submission-email-validation.js";
|
|
7
|
+
import { DUPLICATE_ATTEMPT, FORM_ATTEMPT_FIELD } from "./submission-attempts.js";
|
|
8
|
+
const HONEYPOT_KEY = "_hp";
|
|
9
|
+
// Anonymous visitors have no actor/permission for an administrative command gateway; this public
|
|
10
|
+
// path instead owns validation, throttling and durable submission/event persistence directly.
|
|
11
|
+
/**
|
|
12
|
+
* Binds Tovu's outbox delivery to Jini's anonymous submission service. The package awaits the
|
|
13
|
+
* object-shaped limiter check and queues delivery without awaiting notifications.
|
|
14
|
+
*
|
|
15
|
+
* A repeat of an attempt accepted within the window (same token, or for a request without one the
|
|
16
|
+
* same IP and body) is answered exactly like the original — `accepted` — with no row, no event and
|
|
17
|
+
* no notification. It still passes the slug, honeypot, validation and rate checks first (and so
|
|
18
|
+
* consumes a rate-limit slot, as any request does): the attempt is claimed only where the package
|
|
19
|
+
* opens its persistence transaction, and a copy that must wait for the first copy waits there,
|
|
20
|
+
* outside any database transaction. The `_attempt` field is removed from the body before
|
|
21
|
+
* validation; it is never stored.
|
|
22
|
+
* @returns Accepted for a valid submission, a duplicate, or a honeypot discard; package errors propagate.
|
|
23
|
+
* @complexity O(f + k) package validation plus O(b log b) attempt hashing; fixed-count persistence
|
|
24
|
+
* effects, background dispatch.
|
|
25
|
+
* @example await submitForm({ deps, input: { workspaceId, slug, body, sourceIp } });
|
|
26
|
+
*/
|
|
27
|
+
export async function submitForm(required, optional = {}) {
|
|
28
|
+
const { deps } = required;
|
|
29
|
+
let attemptToken;
|
|
30
|
+
let input = required.input;
|
|
31
|
+
if (optional.attempts) {
|
|
32
|
+
const { [FORM_ATTEMPT_FIELD]: token, ...body } = input.body;
|
|
33
|
+
attemptToken = token;
|
|
34
|
+
input = { ...input, body };
|
|
35
|
+
}
|
|
36
|
+
// Missing and disabled slugs are deliberately indistinguishable to public callers.
|
|
37
|
+
const stored = await deps.definitionRepo.findBySlug({ workspaceId: input.workspaceId, slug: input.slug });
|
|
38
|
+
const definition = stored && optional.definitionView ? optional.definitionView({ definition: stored, body: input.body }) : stored;
|
|
39
|
+
if (definition && optional.validateEmails)
|
|
40
|
+
assertSubmissionEmails({ definition, body: input.body });
|
|
41
|
+
if (!definition || definition.status !== "active") {
|
|
42
|
+
throw new FormDefinitionNotFoundError({ message: `form '${input.slug}' was not found` });
|
|
43
|
+
}
|
|
44
|
+
// Discard before validation, rate limiting or writes; a bot sees the same accepted response
|
|
45
|
+
// without consuming capacity or creating a submission/event.
|
|
46
|
+
if (isHoneypotTripped({ hp: input.body[HONEYPOT_KEY] })) {
|
|
47
|
+
return { status: "accepted" };
|
|
48
|
+
}
|
|
49
|
+
const validation = validateSubmissionPayload({ definition, body: input.body });
|
|
50
|
+
if (!validation.valid) {
|
|
51
|
+
throw new FormSubmissionValidationError({ message: "submission failed field validation", fieldErrors: validation.fieldErrors });
|
|
52
|
+
}
|
|
53
|
+
// Scope by both IP and form so one client's different forms cannot cross-throttle each other.
|
|
54
|
+
const rateLimitKey = buildFormsRateLimitKey({ sourceIp: input.sourceIp, formDefinitionId: definition.id });
|
|
55
|
+
const rateLimitResult = await deps.rateLimiter.check({ key: rateLimitKey });
|
|
56
|
+
if (!rateLimitResult.allowed) {
|
|
57
|
+
throw new FormRateLimitExceededError({ message: `rate limit exceeded for form '${definition.id}'`, retryAfterSeconds: rateLimitResult.retryAfterSeconds });
|
|
58
|
+
}
|
|
59
|
+
const now = kernelNowIso({ clock: deps.clock });
|
|
60
|
+
const submissionId = deps.idGen.newId();
|
|
61
|
+
const submission = {
|
|
62
|
+
id: submissionId,
|
|
63
|
+
workspaceId: input.workspaceId,
|
|
64
|
+
formDefinitionId: definition.id,
|
|
65
|
+
data: validation.data,
|
|
66
|
+
sourceIp: input.sourceIp,
|
|
67
|
+
submittedAt: now,
|
|
68
|
+
};
|
|
69
|
+
const event = {
|
|
70
|
+
id: deps.idGen.newId(),
|
|
71
|
+
name: "form.submission.received",
|
|
72
|
+
occurredAt: now,
|
|
73
|
+
workspaceId: input.workspaceId,
|
|
74
|
+
aggregateId: submissionId,
|
|
75
|
+
payload: { workspaceId: input.workspaceId, formDefinitionId: definition.id, submissionId },
|
|
76
|
+
};
|
|
77
|
+
// Only a persisted accepted submission gets this event; honeypot discards emit nothing. Row and
|
|
78
|
+
// event commit together: a failed enqueue must not leave a stored submission nobody is told about.
|
|
79
|
+
const transaction = deps.transaction ?? ((work) => work());
|
|
80
|
+
const persist = () => transaction(async () => {
|
|
81
|
+
await deps.submissionRepo.create(submission);
|
|
82
|
+
await deps.outbox.enqueue(event);
|
|
83
|
+
});
|
|
84
|
+
if (optional.attempts) {
|
|
85
|
+
// The attempt is claimed only where persistence begins, after validation and rate limiting.
|
|
86
|
+
// A copy waits outside the database transaction; failed first copies free the claim for retry.
|
|
87
|
+
const outcome = await optional.attempts.once({
|
|
88
|
+
key: optional.attempts.key({ input, attemptToken }),
|
|
89
|
+
nowMs: () => deps.clock.nowMs(),
|
|
90
|
+
submit: persist,
|
|
91
|
+
});
|
|
92
|
+
if (outcome === DUPLICATE_ATTEMPT)
|
|
93
|
+
return { status: "accepted" };
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
await persist();
|
|
97
|
+
}
|
|
98
|
+
// Dispatch failures cannot affect an accepted submission or create an unhandled rejection.
|
|
99
|
+
// Never await delivery here: a subscriber sends mail, so awaiting it would reintroduce the
|
|
100
|
+
// response-blocking bug. Persistence/enqueue are awaited; outbox drainage remains background work.
|
|
101
|
+
void Promise.resolve().then(() => (optional.dispatch ?? deps.dispatcher.dispatch.bind(deps.dispatcher))({})).catch((error) => {
|
|
102
|
+
try {
|
|
103
|
+
deps.logger.warn({ message: "form outbox dispatch failed" }, { error });
|
|
104
|
+
}
|
|
105
|
+
catch { /* Logging must not reject background work. */ }
|
|
106
|
+
});
|
|
107
|
+
return { status: "accepted" };
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=submit-service.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { UUID } from "@jini-ai/core/primitives";
|
|
2
|
+
export type FieldType = "text" | "email" | "textarea" | "checkbox";
|
|
3
|
+
export type FormDefinitionStatus = "active" | "disabled";
|
|
4
|
+
export interface FieldDescriptor {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
type: FieldType;
|
|
8
|
+
required: boolean;
|
|
9
|
+
maxLength?: number | null;
|
|
10
|
+
/** Presence distinguishes an authored checkbox value (even "") from the native boolean default. */
|
|
11
|
+
checkboxValue?: string;
|
|
12
|
+
/** Length-bounded and HTML-escaped at rendering; class names are inert after escaping. */
|
|
13
|
+
className?: string;
|
|
14
|
+
/** Attribute names require a closed allowlist: escaping a name cannot make it safe.
|
|
15
|
+
* Values remain plain strings and must be HTML-escaped by the renderer. */
|
|
16
|
+
attributes?: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
export interface NotifyConfig {
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
recipients: string[];
|
|
21
|
+
}
|
|
22
|
+
export interface FormDefinitionRecord {
|
|
23
|
+
id: UUID;
|
|
24
|
+
workspaceId: UUID;
|
|
25
|
+
name: string;
|
|
26
|
+
slug: string;
|
|
27
|
+
fields: FieldDescriptor[];
|
|
28
|
+
notify: NotifyConfig;
|
|
29
|
+
status: FormDefinitionStatus;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
updatedAt: string;
|
|
32
|
+
/** Compare-and-set counter for trash transitions; never an admin-editable field.
|
|
33
|
+
* Every record constructor, including fixtures, must supply it. */
|
|
34
|
+
version: number;
|
|
35
|
+
}
|
|
36
|
+
export interface FormSubmissionRecord {
|
|
37
|
+
id: UUID;
|
|
38
|
+
workspaceId: UUID;
|
|
39
|
+
formDefinitionId: UUID;
|
|
40
|
+
data: Record<string, string | boolean>;
|
|
41
|
+
/** Cleared by metadata retention; answers and the submission itself remain. */
|
|
42
|
+
sourceIp: string | null;
|
|
43
|
+
submittedAt: string;
|
|
44
|
+
}
|
|
45
|
+
export interface FormSubmissionPage {
|
|
46
|
+
items: FormSubmissionRecord[];
|
|
47
|
+
nextCursor: string | null;
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Audited definition writes through the CMS command gateway. Host permissions and reserved slugs are explicit policies; notify, idempotency and optional outbox wiring live in the second argument. */
|
|
2
|
+
import type { ChangeSetRepoPort } from "../core/index.js";
|
|
3
|
+
import type { Clock, IdGenerator, UUID } from "@jini-ai/core/primitives";
|
|
4
|
+
import type { AuthorizeFn, CommandActor } from "../core/index.js";
|
|
5
|
+
import type { FormAuthoringPort, FormCommandExecutorPort, FormDefinitionRepoPort } from "./ports.js";
|
|
6
|
+
import type { FieldDescriptor, FormDefinitionRecord, FormDefinitionStatus, NotifyConfig } from "./types.js";
|
|
7
|
+
import type { FormAuthoring } from "./fields-json.js";
|
|
8
|
+
export interface FormWriteServiceDeps {
|
|
9
|
+
/** Host-bound audited command gateway; no runtime CMS implementation is imported. */
|
|
10
|
+
executeCommand: FormCommandExecutorPort;
|
|
11
|
+
repo: FormDefinitionRepoPort;
|
|
12
|
+
clock: Clock;
|
|
13
|
+
idGen: IdGenerator;
|
|
14
|
+
changeSets: ChangeSetRepoPort;
|
|
15
|
+
permission: string;
|
|
16
|
+
reservedSlugs: ReadonlySet<string>;
|
|
17
|
+
authorize: AuthorizeFn;
|
|
18
|
+
}
|
|
19
|
+
export declare const MAX_NOTIFY_RECIPIENTS = 10;
|
|
20
|
+
export declare const SLUG_PATTERN: RegExp;
|
|
21
|
+
export declare const MAX_NAME_LENGTH = 200;
|
|
22
|
+
export interface CreateFormDefinitionRequired {
|
|
23
|
+
deps: FormWriteServiceDeps;
|
|
24
|
+
input: {
|
|
25
|
+
workspaceId: UUID;
|
|
26
|
+
actor: CommandActor;
|
|
27
|
+
name: string;
|
|
28
|
+
slug: string;
|
|
29
|
+
fields: FieldDescriptor[];
|
|
30
|
+
} & FormAuthoring;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* All definition writes pass through the injected command gateway for authorization, audit
|
|
34
|
+
* capture and optional change-set event enqueue. There is no delete here: permanent removal
|
|
35
|
+
* belongs to a separate trash purge, and the definition repository exposes no delete method.
|
|
36
|
+
* Slug uniqueness is enforced by the repository's unique index, avoiding a racy pre-insert check;
|
|
37
|
+
* repository slug-conflict errors propagate unchanged from the mutation.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createFormDefinition(required: CreateFormDefinitionRequired, optional?: CreateFormDefinitionOptional): Promise<{
|
|
40
|
+
definition: FormDefinitionRecord;
|
|
41
|
+
}>;
|
|
42
|
+
export interface UpdateFormDefinitionRequired {
|
|
43
|
+
deps: FormWriteServiceDeps;
|
|
44
|
+
input: {
|
|
45
|
+
workspaceId: UUID;
|
|
46
|
+
actor: CommandActor;
|
|
47
|
+
formId: UUID;
|
|
48
|
+
patch: {
|
|
49
|
+
name?: string;
|
|
50
|
+
fields?: FieldDescriptor[];
|
|
51
|
+
notify?: NotifyConfig;
|
|
52
|
+
slug?: string;
|
|
53
|
+
} & FormAuthoring;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export declare function updateFormDefinition(required: UpdateFormDefinitionRequired, optional?: UpdateFormDefinitionOptional): Promise<{
|
|
57
|
+
definition: FormDefinitionRecord;
|
|
58
|
+
}>;
|
|
59
|
+
export interface SetFormDefinitionStatusRequired {
|
|
60
|
+
deps: FormWriteServiceDeps;
|
|
61
|
+
input: {
|
|
62
|
+
workspaceId: UUID;
|
|
63
|
+
actor: CommandActor;
|
|
64
|
+
formId: UUID;
|
|
65
|
+
status: FormDefinitionStatus;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Status changes preserve the row; deletion requires the separate trash lifecycle. */
|
|
69
|
+
export declare function setFormDefinitionStatus(required: SetFormDefinitionStatusRequired, optional?: FormWriteOptional): Promise<{
|
|
70
|
+
definition: FormDefinitionRecord;
|
|
71
|
+
}>;
|
|
72
|
+
export interface FormWriteOptional {
|
|
73
|
+
idempotencyKey?: string;
|
|
74
|
+
outbox?: import("../core/index.js").OutboxPort;
|
|
75
|
+
}
|
|
76
|
+
export interface CreateFormDefinitionOptional extends FormWriteOptional {
|
|
77
|
+
notify?: NotifyConfig;
|
|
78
|
+
authoring?: FormAuthoringPort;
|
|
79
|
+
}
|
|
80
|
+
export interface UpdateFormDefinitionOptional extends FormWriteOptional {
|
|
81
|
+
authoring?: FormAuthoringPort;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=write-service.d.ts.map
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `write-service.ts` — the admin-CRUD write chokepoint (SPEC-010 REQ-01..04, ADR-PIPE-010).
|
|
3
|
+
*
|
|
4
|
+
* Purpose:
|
|
5
|
+
* `createFormDefinition`/`updateFormDefinition`/`setFormDefinitionStatus`, each wrapping the
|
|
6
|
+
* existing core/commands `executeCommand` gateway (mirrors `posts/create.ts`'s pattern — the
|
|
7
|
+
* gateway captures the auditable change-set record, authorizes `admin.forms.manage`, and enqueues
|
|
8
|
+
* `change-set.applied` onto the outbox). Never exposes a delete: a form definition is removed
|
|
9
|
+
* permanently only by a Trash purge (owner ruling 2026-09-21, superseding the original INV-08
|
|
10
|
+
* "never deleted" wording) — the repo port itself has no delete method for this file to call.
|
|
11
|
+
*
|
|
12
|
+
* Slug-uniqueness relies on the DB unique index (`db/schema.sqlite.ts`), not an app-level check
|
|
13
|
+
* (behavior.spec.md §6.1) — `repo.memory.ts`/`repo.ts` both map a conflicting insert to
|
|
14
|
+
* `FormSlugConflictError`, which this file lets propagate unchanged out of `mutation.execute()`.
|
|
15
|
+
*/
|
|
16
|
+
import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
|
|
17
|
+
import { FormDefinitionNotFoundError, FormFieldValidationError } from "./errors.js";
|
|
18
|
+
import { validateFieldDescriptors } from "./forms.js";
|
|
19
|
+
// Export validation bounds so host tool schemas can share them rather than drift from the service.
|
|
20
|
+
export const MAX_NOTIFY_RECIPIENTS = 10;
|
|
21
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
22
|
+
export const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
23
|
+
const MIN_NAME_LENGTH = 1;
|
|
24
|
+
export const MAX_NAME_LENGTH = 200;
|
|
25
|
+
function defaultNotify() {
|
|
26
|
+
return { enabled: false, recipients: [] };
|
|
27
|
+
}
|
|
28
|
+
function validateNotify(notify) {
|
|
29
|
+
const resolved = notify ?? defaultNotify();
|
|
30
|
+
if (!Array.isArray(resolved.recipients)) {
|
|
31
|
+
throw new FormFieldValidationError({ message: "notify.recipients must be an array", fieldErrors: [
|
|
32
|
+
{ field: "notify.recipients", reason: "must be an array" },
|
|
33
|
+
] });
|
|
34
|
+
}
|
|
35
|
+
if (typeof resolved.enabled !== "boolean") {
|
|
36
|
+
throw new FormFieldValidationError({ message: "notify.enabled must be a boolean", fieldErrors: [
|
|
37
|
+
{ field: "notify.enabled", reason: "must be a boolean" },
|
|
38
|
+
] });
|
|
39
|
+
}
|
|
40
|
+
if (resolved.recipients.length > MAX_NOTIFY_RECIPIENTS) {
|
|
41
|
+
throw new FormFieldValidationError({ message: `notify.recipients may not exceed ${MAX_NOTIFY_RECIPIENTS} addresses`, fieldErrors: [{ field: "notify.recipients", reason: `at most ${MAX_NOTIFY_RECIPIENTS} recipients are allowed` }] });
|
|
42
|
+
}
|
|
43
|
+
for (const recipient of resolved.recipients) {
|
|
44
|
+
if (!EMAIL_PATTERN.test(recipient)) {
|
|
45
|
+
throw new FormFieldValidationError({ message: `'${recipient}' is not a valid email address`, fieldErrors: [
|
|
46
|
+
{ field: "notify.recipients", reason: `'${recipient}' is not a valid email address` },
|
|
47
|
+
] });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return resolved;
|
|
51
|
+
}
|
|
52
|
+
function validateNameAndSlug(name, slug, reservedSlugs) {
|
|
53
|
+
if (name.length < MIN_NAME_LENGTH || name.length > MAX_NAME_LENGTH) {
|
|
54
|
+
throw new FormFieldValidationError({ message: `name must be ${MIN_NAME_LENGTH}-${MAX_NAME_LENGTH} characters`, fieldErrors: [
|
|
55
|
+
{ field: "name", reason: `must be ${MIN_NAME_LENGTH}-${MAX_NAME_LENGTH} characters` },
|
|
56
|
+
] });
|
|
57
|
+
}
|
|
58
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
59
|
+
throw new FormFieldValidationError({ message: "slug must match ^[a-z0-9][a-z0-9-]{0,63}$", fieldErrors: [
|
|
60
|
+
{ field: "slug", reason: "must match ^[a-z0-9][a-z0-9-]{0,63}$" },
|
|
61
|
+
] });
|
|
62
|
+
}
|
|
63
|
+
if (reservedSlugs.has(slug)) {
|
|
64
|
+
throw new FormFieldValidationError({ message: `slug '${slug}' is reserved`, fieldErrors: [
|
|
65
|
+
{ field: "slug", reason: `'${slug}' is reserved by the host` },
|
|
66
|
+
] });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function assertValidFields(fields) {
|
|
70
|
+
const validation = validateFieldDescriptors({ fields });
|
|
71
|
+
if (!validation.valid) {
|
|
72
|
+
throw new FormFieldValidationError({ message: "one or more field descriptors are invalid", fieldErrors: validation.fieldErrors });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* All definition writes pass through the injected command gateway for authorization, audit
|
|
77
|
+
* capture and optional change-set event enqueue. There is no delete here: permanent removal
|
|
78
|
+
* belongs to a separate trash purge, and the definition repository exposes no delete method.
|
|
79
|
+
* Slug uniqueness is enforced by the repository's unique index, avoiding a racy pre-insert check;
|
|
80
|
+
* repository slug-conflict errors propagate unchanged from the mutation.
|
|
81
|
+
*/
|
|
82
|
+
export async function createFormDefinition(required, optional = {}) {
|
|
83
|
+
let { deps, input } = required;
|
|
84
|
+
if (optional.authoring) {
|
|
85
|
+
const authored = await optional.authoring.prepare({ deps, input });
|
|
86
|
+
if (authored) {
|
|
87
|
+
deps = { ...deps, repo: optional.authoring.with({ repo: deps.repo, authoring: authored }) };
|
|
88
|
+
input = { ...input, ...(authored.fields ? { fields: authored.fields } : {}) };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
validateNameAndSlug(input.name, input.slug, deps.reservedSlugs);
|
|
92
|
+
assertValidFields(input.fields);
|
|
93
|
+
const notify = validateNotify(optional.notify);
|
|
94
|
+
const definitionId = deps.idGen.newId();
|
|
95
|
+
const { result } = await deps.executeCommand({
|
|
96
|
+
deps: {
|
|
97
|
+
clock: deps.clock,
|
|
98
|
+
idGen: deps.idGen,
|
|
99
|
+
changeSets: deps.changeSets,
|
|
100
|
+
outbox: optional.outbox,
|
|
101
|
+
authorize: deps.authorize,
|
|
102
|
+
},
|
|
103
|
+
command: {
|
|
104
|
+
workspaceId: input.workspaceId,
|
|
105
|
+
actor: input.actor,
|
|
106
|
+
summary: `Create form definition '${input.slug}'`,
|
|
107
|
+
idempotencyKey: optional.idempotencyKey,
|
|
108
|
+
permission: deps.permission,
|
|
109
|
+
},
|
|
110
|
+
mutation: {
|
|
111
|
+
entityType: "form_definition",
|
|
112
|
+
entityId: definitionId,
|
|
113
|
+
operation: "create",
|
|
114
|
+
captureInverse: async () => null,
|
|
115
|
+
execute: async () => {
|
|
116
|
+
const now = kernelNowIso({ clock: deps.clock });
|
|
117
|
+
const definition = {
|
|
118
|
+
id: definitionId,
|
|
119
|
+
workspaceId: input.workspaceId,
|
|
120
|
+
name: input.name,
|
|
121
|
+
slug: input.slug,
|
|
122
|
+
fields: input.fields,
|
|
123
|
+
notify,
|
|
124
|
+
status: "active",
|
|
125
|
+
createdAt: now,
|
|
126
|
+
updatedAt: now,
|
|
127
|
+
// Start the optimistic-concurrency version at 1 for the trash compare-and-set lifecycle.
|
|
128
|
+
version: 1,
|
|
129
|
+
};
|
|
130
|
+
await deps.repo.create(definition);
|
|
131
|
+
return { definition };
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
export async function updateFormDefinition(required, optional = {}) {
|
|
138
|
+
if (optional.authoring)
|
|
139
|
+
return updateAuthoredFormDefinition(required, optional);
|
|
140
|
+
const { deps, input } = required;
|
|
141
|
+
const { result } = await deps.executeCommand({
|
|
142
|
+
deps: {
|
|
143
|
+
clock: deps.clock,
|
|
144
|
+
idGen: deps.idGen,
|
|
145
|
+
changeSets: deps.changeSets,
|
|
146
|
+
outbox: optional.outbox,
|
|
147
|
+
authorize: deps.authorize,
|
|
148
|
+
},
|
|
149
|
+
command: {
|
|
150
|
+
workspaceId: input.workspaceId,
|
|
151
|
+
actor: input.actor,
|
|
152
|
+
summary: `Update form definition '${input.formId}'`,
|
|
153
|
+
idempotencyKey: optional.idempotencyKey,
|
|
154
|
+
permission: deps.permission,
|
|
155
|
+
},
|
|
156
|
+
mutation: {
|
|
157
|
+
entityType: "form_definition",
|
|
158
|
+
entityId: input.formId,
|
|
159
|
+
operation: "update",
|
|
160
|
+
captureInverse: async () => null,
|
|
161
|
+
execute: async () => {
|
|
162
|
+
const existing = await deps.repo.findById({ workspaceId: input.workspaceId, id: input.formId });
|
|
163
|
+
if (!existing) {
|
|
164
|
+
throw new FormDefinitionNotFoundError({ message: `form definition '${input.formId}' was not found` });
|
|
165
|
+
}
|
|
166
|
+
const name = input.patch.name !== undefined ? input.patch.name : existing.name;
|
|
167
|
+
if (input.patch.name !== undefined) {
|
|
168
|
+
validateNameAndSlug(name, existing.slug, deps.reservedSlugs);
|
|
169
|
+
}
|
|
170
|
+
let fields = existing.fields;
|
|
171
|
+
if (input.patch.fields !== undefined) {
|
|
172
|
+
assertValidFields(input.patch.fields);
|
|
173
|
+
const existingIds = new Set(existing.fields.map((f) => f.id));
|
|
174
|
+
const newIds = new Set(input.patch.fields.map((f) => f.id));
|
|
175
|
+
const missing = [...existingIds].filter((id) => !newIds.has(id));
|
|
176
|
+
if (missing.length > 0) {
|
|
177
|
+
throw new FormFieldValidationError({ message: `patch omits existing field id(s): ${missing.join(", ")} (behavior.spec.md §1.2)`, fieldErrors: missing.map((id) => ({ field: id, reason: "existing field ids cannot be removed" })) });
|
|
178
|
+
}
|
|
179
|
+
fields = input.patch.fields;
|
|
180
|
+
}
|
|
181
|
+
const notify = input.patch.notify !== undefined ? validateNotify(input.patch.notify) : existing.notify;
|
|
182
|
+
const definition = {
|
|
183
|
+
...existing,
|
|
184
|
+
name,
|
|
185
|
+
fields,
|
|
186
|
+
notify,
|
|
187
|
+
slug: existing.slug,
|
|
188
|
+
updatedAt: kernelNowIso({ clock: deps.clock }),
|
|
189
|
+
};
|
|
190
|
+
await deps.repo.update(definition);
|
|
191
|
+
return { definition };
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
/** Status changes preserve the row; deletion requires the separate trash lifecycle. */
|
|
198
|
+
export async function setFormDefinitionStatus(required, optional = {}) {
|
|
199
|
+
const { deps, input } = required;
|
|
200
|
+
const { result } = await deps.executeCommand({
|
|
201
|
+
deps: {
|
|
202
|
+
clock: deps.clock,
|
|
203
|
+
idGen: deps.idGen,
|
|
204
|
+
changeSets: deps.changeSets,
|
|
205
|
+
outbox: optional.outbox,
|
|
206
|
+
authorize: deps.authorize,
|
|
207
|
+
},
|
|
208
|
+
command: {
|
|
209
|
+
workspaceId: input.workspaceId,
|
|
210
|
+
actor: input.actor,
|
|
211
|
+
summary: `Set form definition '${input.formId}' status to '${input.status}'`,
|
|
212
|
+
idempotencyKey: optional.idempotencyKey,
|
|
213
|
+
permission: deps.permission,
|
|
214
|
+
},
|
|
215
|
+
mutation: {
|
|
216
|
+
entityType: "form_definition",
|
|
217
|
+
entityId: input.formId,
|
|
218
|
+
operation: "update",
|
|
219
|
+
captureInverse: async () => null,
|
|
220
|
+
execute: async () => {
|
|
221
|
+
const existing = await deps.repo.findById({ workspaceId: input.workspaceId, id: input.formId });
|
|
222
|
+
if (!existing) {
|
|
223
|
+
throw new FormDefinitionNotFoundError({ message: `form definition '${input.formId}' was not found` });
|
|
224
|
+
}
|
|
225
|
+
const definition = {
|
|
226
|
+
...existing,
|
|
227
|
+
status: input.status,
|
|
228
|
+
updatedAt: kernelNowIso({ clock: deps.clock }),
|
|
229
|
+
};
|
|
230
|
+
await deps.repo.update(definition);
|
|
231
|
+
return { definition };
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
/** Authoring pre-reads are authorized once; the audited gateway replays that decision.
|
|
238
|
+
* @complexity O(f + h) authoring/validation, with fixed-count repository and command calls. */
|
|
239
|
+
async function updateAuthoredFormDefinition({ deps, input }, optional) {
|
|
240
|
+
// Authorize BEFORE the authoring pre-read below, so a denied caller is refused before any
|
|
241
|
+
// lookup and the refusal never discloses whether the form exists (5e9f338f6 put the read
|
|
242
|
+
// first). The gateway then replays this one decision instead of evaluating a second time —
|
|
243
|
+
// ADR-021 §2 "one evaluator". Its only authorize() caller is executeCommand's single gate,
|
|
244
|
+
// pinned to FORMS_PERMISSION by packageDeps; prepareFormAuthoring keeps the real authorize.
|
|
245
|
+
const decision = await deps.authorize({
|
|
246
|
+
principalId: input.actor.id,
|
|
247
|
+
permission: deps.permission,
|
|
248
|
+
workspaceId: input.workspaceId,
|
|
249
|
+
entityType: "form_definition",
|
|
250
|
+
entityId: input.formId,
|
|
251
|
+
});
|
|
252
|
+
const gatewayDeps = { ...deps, authorize: async () => decision };
|
|
253
|
+
const { mode, html, ...builderPatch } = input.patch;
|
|
254
|
+
const { authoring, ...writeOptions } = optional;
|
|
255
|
+
// Denied: skip the pre-read entirely; the gateway throws its own ForbiddenError.
|
|
256
|
+
if (!decision.allowed) {
|
|
257
|
+
return updateFormDefinition({ deps: gatewayDeps, input: { ...input, patch: builderPatch } }, writeOptions);
|
|
258
|
+
}
|
|
259
|
+
const existing = await deps.repo.findById({ workspaceId: input.workspaceId, id: input.formId });
|
|
260
|
+
const authored = await authoring.prepare({
|
|
261
|
+
deps, input: { workspaceId: input.workspaceId, actor: input.actor, ...input.patch }, ...(existing ? { existing } : {}),
|
|
262
|
+
});
|
|
263
|
+
const boundDeps = authored ? {
|
|
264
|
+
...gatewayDeps,
|
|
265
|
+
repo: authoring.with({ repo: deps.repo, authoring: authored }, { replaceFields: authored.mode === "html" || (existing?.mode === "html" && input.patch.fields !== undefined) }),
|
|
266
|
+
} : gatewayDeps;
|
|
267
|
+
return updateFormDefinition({
|
|
268
|
+
deps: boundDeps, input: { ...input, patch: { ...builderPatch, ...(authored?.fields ? { fields: authored.fields } : {}) } },
|
|
269
|
+
}, writeOptions);
|
|
270
|
+
}
|
|
271
|
+
//# sourceMappingURL=write-service.js.map
|