@jini-ai/cms 0.5.2 → 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 +15 -0
- package/README.md +14 -8
- package/dist/comments/errors.d.ts +23 -0
- package/dist/comments/errors.js +25 -0
- package/dist/comments/express/index.d.ts +2 -0
- package/dist/comments/express/index.js +2 -0
- package/dist/comments/express/submit-handler.d.ts +30 -0
- package/dist/comments/express/submit-handler.js +85 -0
- package/dist/comments/hooks.d.ts +39 -0
- package/dist/comments/hooks.js +41 -0
- package/dist/comments/index.d.ts +11 -0
- package/dist/comments/index.js +11 -0
- package/dist/comments/ingress.d.ts +57 -0
- package/dist/comments/ingress.js +173 -0
- package/dist/comments/module.d.ts +94 -0
- package/dist/comments/module.js +73 -0
- package/dist/comments/ports.d.ts +184 -0
- package/dist/comments/ports.js +2 -0
- package/dist/comments/repo.memory.d.ts +70 -0
- package/dist/comments/repo.memory.js +120 -0
- package/dist/comments/sanitize.d.ts +15 -0
- package/dist/comments/sanitize.js +31 -0
- package/dist/comments/spam.heuristic.d.ts +8 -0
- package/dist/comments/spam.heuristic.js +54 -0
- package/dist/comments/sql/index.d.ts +3 -0
- package/dist/comments/sql/index.js +3 -0
- package/dist/comments/sql/repo.d.ts +77 -0
- package/dist/comments/sql/repo.js +190 -0
- package/dist/comments/sql/repo.rows.d.ts +49 -0
- package/dist/comments/sql/repo.rows.js +71 -0
- package/dist/comments/types.d.ts +187 -0
- package/dist/comments/types.js +39 -0
- package/dist/comments/write-service.d.ts +121 -0
- package/dist/comments/write-service.js +144 -0
- package/dist/core/commands/command.d.ts +2 -9
- package/dist/core/commands/command.js +2 -9
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +1 -0
- package/dist/core/keyset-cursor.d.ts +25 -0
- package/dist/core/keyset-cursor.js +20 -0
- package/dist/core/ports.d.ts +30 -0
- package/dist/core/ports.js +0 -1
- package/dist/core/tools/args.d.ts +1 -46
- package/dist/core/tools/args.js +1 -44
- 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/dist/media/index.d.ts +0 -3
- package/dist/media/index.js +1 -4
- package/dist/media/node.d.ts +5 -0
- package/dist/media/node.js +6 -0
- package/dist/redirects/capture.d.ts +80 -0
- package/dist/redirects/capture.js +66 -0
- package/dist/redirects/express/index.d.ts +27 -0
- package/dist/redirects/express/index.js +40 -0
- package/dist/redirects/hit-sink.d.ts +56 -0
- package/dist/redirects/hit-sink.js +44 -0
- package/dist/redirects/host-config/index.d.ts +40 -0
- package/dist/redirects/host-config/index.js +145 -0
- package/dist/redirects/index.d.ts +14 -0
- package/dist/redirects/index.js +14 -0
- package/dist/redirects/matcher.d.ts +58 -0
- package/dist/redirects/matcher.js +144 -0
- package/dist/redirects/order.d.ts +30 -0
- package/dist/redirects/order.js +26 -0
- package/dist/redirects/ports.d.ts +242 -0
- package/dist/redirects/ports.internal.d.ts +57 -0
- package/dist/redirects/ports.internal.js +14 -0
- package/dist/redirects/ports.js +2 -0
- package/dist/redirects/redirects.d.ts +226 -0
- package/dist/redirects/redirects.js +590 -0
- package/dist/redirects/referrer-alias.d.ts +31 -0
- package/dist/redirects/referrer-alias.js +33 -0
- package/dist/redirects/repo.memory.d.ts +51 -0
- package/dist/redirects/repo.memory.js +98 -0
- package/dist/redirects/reserved-destination.d.ts +58 -0
- package/dist/redirects/reserved-destination.js +59 -0
- package/dist/redirects/resolver.d.ts +100 -0
- package/dist/redirects/resolver.js +87 -0
- package/dist/redirects/spa/index.d.ts +30 -0
- package/dist/redirects/spa/index.js +27 -0
- package/dist/redirects/sql/index.d.ts +4 -0
- package/dist/redirects/sql/index.js +4 -0
- package/dist/redirects/sql/repo.d.ts +82 -0
- package/dist/redirects/sql/repo.js +143 -0
- package/dist/redirects/sql/repo.rows.d.ts +49 -0
- package/dist/redirects/sql/repo.rows.js +79 -0
- package/dist/redirects/sql/row-types.d.ts +34 -0
- package/dist/redirects/sql/row-types.js +2 -0
- package/dist/redirects/target-gate.d.ts +30 -0
- package/dist/redirects/target-gate.js +85 -0
- package/dist/redirects/types.d.ts +213 -0
- package/dist/redirects/types.js +42 -0
- package/dist/seo/absolute-url.d.ts +58 -0
- package/dist/seo/absolute-url.js +117 -0
- package/dist/seo/dom/index.d.ts +14 -0
- package/dist/seo/dom/index.js +52 -0
- package/dist/seo/errors.d.ts +35 -0
- package/dist/seo/errors.js +35 -0
- package/dist/seo/feed.d.ts +49 -0
- package/dist/seo/feed.js +61 -0
- package/dist/seo/index.d.ts +13 -0
- package/dist/seo/index.js +13 -0
- package/dist/seo/media.d.ts +57 -0
- package/dist/seo/media.js +134 -0
- package/dist/seo/page-head.d.ts +18 -0
- package/dist/seo/page-head.js +114 -0
- package/dist/seo/ports.d.ts +221 -0
- package/dist/seo/ports.js +27 -0
- package/dist/seo/seo.d.ts +73 -0
- package/dist/seo/seo.js +230 -0
- package/dist/seo/sitemap.d.ts +139 -0
- package/dist/seo/sitemap.js +217 -0
- package/dist/seo/types.d.ts +278 -0
- package/dist/seo/types.js +2 -0
- package/dist/seo/write-service.d.ts +79 -0
- package/dist/seo/write-service.js +273 -0
- package/dist/seo/xml-escape.d.ts +16 -0
- package/dist/seo/xml-escape.js +19 -0
- package/dist/settings/express/cms-adapter.d.ts +20 -0
- package/dist/settings/express/cms-adapter.js +124 -0
- package/dist/settings/express/contracts.d.ts +135 -0
- package/dist/settings/express/contracts.js +2 -0
- package/dist/settings/express/http.d.ts +10 -0
- package/dist/settings/express/http.js +211 -0
- package/dist/settings/express/index.d.ts +5 -0
- package/dist/settings/express/index.js +5 -0
- package/dist/settings/express/shared.d.ts +68 -0
- package/dist/settings/express/shared.js +54 -0
- package/dist/settings/express/stream.d.ts +24 -0
- package/dist/settings/express/stream.js +147 -0
- package/dist/settings/index.d.ts +3 -38
- package/dist/settings/index.js +3 -35
- package/dist/taxonomy/write-service.d.ts +2 -30
- package/dist/widgets/authorize-helper.d.ts +42 -0
- package/dist/widgets/authorize-helper.js +16 -0
- package/dist/widgets/concurrency.d.ts +12 -0
- package/dist/widgets/concurrency.js +44 -0
- package/dist/widgets/config-validation.d.ts +34 -0
- package/dist/widgets/config-validation.js +125 -0
- package/dist/widgets/embed-service.d.ts +133 -0
- package/dist/widgets/embed-service.js +460 -0
- package/dist/widgets/embed-validation.d.ts +23 -0
- package/dist/widgets/embed-validation.js +38 -0
- package/dist/widgets/entry-payload.d.ts +103 -0
- package/dist/widgets/entry-payload.js +160 -0
- package/dist/widgets/errors.d.ts +136 -0
- package/dist/widgets/errors.js +136 -0
- package/dist/widgets/host-ports.d.ts +149 -0
- package/dist/widgets/host-ports.js +2 -0
- package/dist/widgets/html/html-embeds.d.ts +247 -0
- package/dist/widgets/html/html-embeds.js +408 -0
- package/dist/widgets/html/index.d.ts +5 -0
- package/dist/widgets/html/index.js +4 -0
- package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
- package/dist/widgets/html/legacy-widget-markers.js +45 -0
- package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
- package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
- package/dist/widgets/index.d.ts +20 -0
- package/dist/widgets/index.js +19 -0
- package/dist/widgets/markers/html-attributes.d.ts +98 -0
- package/dist/widgets/markers/html-attributes.js +216 -0
- package/dist/widgets/markers/index.d.ts +3 -0
- package/dist/widgets/markers/index.js +3 -0
- package/dist/widgets/markers/marker.d.ts +496 -0
- package/dist/widgets/markers/marker.js +780 -0
- package/dist/widgets/page-embed-types.d.ts +42 -0
- package/dist/widgets/page-embed-types.js +42 -0
- package/dist/widgets/ports.d.ts +142 -0
- package/dist/widgets/ports.js +2 -0
- package/dist/widgets/read-service.d.ts +81 -0
- package/dist/widgets/read-service.js +79 -0
- package/dist/widgets/region-area-service.d.ts +110 -0
- package/dist/widgets/region-area-service.js +180 -0
- package/dist/widgets/registry.d.ts +217 -0
- package/dist/widgets/registry.js +172 -0
- package/dist/widgets/repo.memory.d.ts +41 -0
- package/dist/widgets/repo.memory.js +48 -0
- package/dist/widgets/resolver-service.d.ts +209 -0
- package/dist/widgets/resolver-service.js +909 -0
- package/dist/widgets/resolvers/contact-form.d.ts +26 -0
- package/dist/widgets/resolvers/contact-form.js +54 -0
- package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
- package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
- package/dist/widgets/resolvers/index.d.ts +65 -0
- package/dist/widgets/resolvers/index.js +155 -0
- package/dist/widgets/resolvers/menu.d.ts +42 -0
- package/dist/widgets/resolvers/menu.js +43 -0
- package/dist/widgets/resolvers/ports.d.ts +82 -0
- package/dist/widgets/resolvers/ports.js +2 -0
- package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
- package/dist/widgets/resolvers/recent-entries.js +165 -0
- package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
- package/dist/widgets/resolvers/taxonomy.js +34 -0
- package/dist/widgets/sql/index.d.ts +3 -0
- package/dist/widgets/sql/index.js +3 -0
- package/dist/widgets/sql/repo.d.ts +41 -0
- package/dist/widgets/sql/repo.js +82 -0
- package/dist/widgets/sql/tables.d.ts +13 -0
- package/dist/widgets/sql/tables.js +2 -0
- package/dist/widgets/types.d.ts +213 -0
- package/dist/widgets/types.js +17 -0
- package/dist/widgets/where-used.d.ts +41 -0
- package/dist/widgets/where-used.js +11 -0
- package/dist/widgets/write-service.d.ts +204 -0
- package/dist/widgets/write-service.js +332 -0
- package/dist/workspace/delete.d.ts +1 -1
- package/package.json +220 -12
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { FormSlugConflictError } from "../errors.js";
|
|
2
|
+
import { toDefinitionRecord, toDefinitionRow, toSubmissionRecord, toSubmissionRow, updatableDefinitionColumns, } from "./repo.rows.js";
|
|
3
|
+
/**
|
|
4
|
+
* @file THE forms repositories: one Kysely query body for every database the storage kernel drives
|
|
5
|
+
* (SQLite, PGlite, Postgres). Every statement goes through `kernel.run` and is awaited.
|
|
6
|
+
*
|
|
7
|
+
* `create`'s slug uniqueness is enforced by the real unique index
|
|
8
|
+
* (`form_definitions_workspace_slug_unique`) — the tie-break of behavior.spec.md §6.1 — but the
|
|
9
|
+
* conflict is detected by a check inside a transaction holding the workspace's forms lock, not by
|
|
10
|
+
* catching the violation: on Postgres a failed statement aborts the surrounding transaction, so a
|
|
11
|
+
* follow-up "is it in the Trash?" query could not run. The index stays as the backstop against a
|
|
12
|
+
* writer outside this process.
|
|
13
|
+
*/
|
|
14
|
+
export class SqlFormDefinitionRepo {
|
|
15
|
+
kernel;
|
|
16
|
+
tables;
|
|
17
|
+
constructor({ kernel, tables }, _optional = {}) { this.kernel = kernel; this.tables = tables; }
|
|
18
|
+
/** Trash-aware: `deleted_at IS NULL` excludes a trashed row (`ports.ts`'s fail-closed-reads doc). */
|
|
19
|
+
async findById(required) {
|
|
20
|
+
const row = await this.kernel.run((db) => db
|
|
21
|
+
.selectFrom(this.tables.definitions)
|
|
22
|
+
.selectAll()
|
|
23
|
+
.where("workspace_id", "=", required.workspaceId)
|
|
24
|
+
.where("id", "=", required.id)
|
|
25
|
+
.where("deleted_at", "is", null)
|
|
26
|
+
.limit(1)
|
|
27
|
+
.executeTakeFirst());
|
|
28
|
+
return row ? toDefinitionRecord(row) : null;
|
|
29
|
+
}
|
|
30
|
+
/** Trash-aware, as {@link findById}. */
|
|
31
|
+
async findBySlug(required) {
|
|
32
|
+
const row = await this.kernel.run((db) => db
|
|
33
|
+
.selectFrom(this.tables.definitions)
|
|
34
|
+
.selectAll()
|
|
35
|
+
.where("workspace_id", "=", required.workspaceId)
|
|
36
|
+
.where("slug", "=", required.slug)
|
|
37
|
+
.where("deleted_at", "is", null)
|
|
38
|
+
.limit(1)
|
|
39
|
+
.executeTakeFirst());
|
|
40
|
+
return row ? toDefinitionRecord(row) : null;
|
|
41
|
+
}
|
|
42
|
+
/** Trash-aware, as {@link findById}. */
|
|
43
|
+
async list(required) {
|
|
44
|
+
const rows = await this.kernel.run((db) => db
|
|
45
|
+
.selectFrom(this.tables.definitions)
|
|
46
|
+
.selectAll()
|
|
47
|
+
.where("workspace_id", "=", required.workspaceId)
|
|
48
|
+
.where("deleted_at", "is", null)
|
|
49
|
+
.execute());
|
|
50
|
+
return rows.map((row) => toDefinitionRecord(row));
|
|
51
|
+
}
|
|
52
|
+
async create(record) {
|
|
53
|
+
await this.kernel.transaction(async () => {
|
|
54
|
+
await this.kernel.lockKey(`forms:${record.workspaceId}`);
|
|
55
|
+
// Trash-blind, deliberately: the unique index has no `WHERE deleted_at IS NULL` clause, so a
|
|
56
|
+
// trashed row's slug is still "taken" — the conflict is real either way, only the remedy
|
|
57
|
+
// differs, which is why the message names it.
|
|
58
|
+
const conflicting = await this.kernel.run((db) => db
|
|
59
|
+
.selectFrom(this.tables.definitions)
|
|
60
|
+
.select("deleted_at")
|
|
61
|
+
.where("workspace_id", "=", record.workspaceId)
|
|
62
|
+
.where("slug", "=", record.slug)
|
|
63
|
+
.limit(1)
|
|
64
|
+
.executeTakeFirst());
|
|
65
|
+
if (conflicting) {
|
|
66
|
+
throw new FormSlugConflictError({
|
|
67
|
+
message: conflicting.deleted_at !== null
|
|
68
|
+
? `a form with slug '${record.slug}' is in the Trash — restore it, or delete it permanently from the Trash, to reuse the slug`
|
|
69
|
+
: `a form with slug '${record.slug}' already exists`,
|
|
70
|
+
slug: record.slug,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
await this.kernel.run((db) => db.insertInto(this.tables.definitions).values(toDefinitionRow(record)).execute());
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Update-only path for a LIVE row. `version`/`deleted_at` are deliberately excluded from the SET
|
|
78
|
+
* list (only the Trash's compare-and-set may move `version`), and `deleted_at IS NULL` in the
|
|
79
|
+
* WHERE means the UPDATE simply matches zero rows if the record has since been trashed — a stale
|
|
80
|
+
* in-hand copy can never clear a marker it never knew was set.
|
|
81
|
+
*/
|
|
82
|
+
async update(record) {
|
|
83
|
+
const set = updatableDefinitionColumns(toDefinitionRow(record));
|
|
84
|
+
await this.kernel.run((db) => db
|
|
85
|
+
.updateTable(this.tables.definitions)
|
|
86
|
+
.set(set)
|
|
87
|
+
.where("workspace_id", "=", record.workspaceId)
|
|
88
|
+
.where("id", "=", record.id)
|
|
89
|
+
.where("deleted_at", "is", null)
|
|
90
|
+
.execute());
|
|
91
|
+
}
|
|
92
|
+
/** Trash-BLIND — see `ports.ts`'s doc for why this is the one deliberate exception. */
|
|
93
|
+
async isSlugTaken(required) {
|
|
94
|
+
const row = await this.kernel.run((db) => db
|
|
95
|
+
.selectFrom(this.tables.definitions)
|
|
96
|
+
.select("id")
|
|
97
|
+
.where("workspace_id", "=", required.workspaceId)
|
|
98
|
+
.where("slug", "=", required.slug)
|
|
99
|
+
.limit(1)
|
|
100
|
+
.executeTakeFirst());
|
|
101
|
+
return row !== undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export class SqlFormSubmissionRepo {
|
|
105
|
+
kernel;
|
|
106
|
+
tables;
|
|
107
|
+
constructor({ kernel, tables }, _optional = {}) { this.kernel = kernel; this.tables = tables; }
|
|
108
|
+
async findById(required) {
|
|
109
|
+
const row = await this.kernel.run((db) => db
|
|
110
|
+
.selectFrom(this.tables.submissions)
|
|
111
|
+
.selectAll()
|
|
112
|
+
.where("workspace_id", "=", required.workspaceId)
|
|
113
|
+
.where("id", "=", required.id)
|
|
114
|
+
.where("deleted_at", "is", null)
|
|
115
|
+
.limit(1)
|
|
116
|
+
.executeTakeFirst());
|
|
117
|
+
return row ? toSubmissionRecord(row) : null;
|
|
118
|
+
}
|
|
119
|
+
async create(record) {
|
|
120
|
+
await this.kernel.run((db) => db.insertInto(this.tables.submissions).values(toSubmissionRow(record)).execute());
|
|
121
|
+
}
|
|
122
|
+
/** The content kernel's transaction; a nested call joins the open one.
|
|
123
|
+
* @complexity O(1) beyond `work`. */
|
|
124
|
+
transaction(work) {
|
|
125
|
+
return this.kernel.transaction(work);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Newest-first (`submitted_at` desc, `id` desc tie-break, behavior.spec.md §2.1). The cursor
|
|
129
|
+
* resumes strictly after the previously returned page's last row on that same ordering; an unknown
|
|
130
|
+
* cursor id is ignored (first page), as before.
|
|
131
|
+
*
|
|
132
|
+
* @complexity O(limit) rows read via the definition index; two statements when a cursor is given.
|
|
133
|
+
*/
|
|
134
|
+
async listByDefinition(required, optional = {}) {
|
|
135
|
+
const cursorRow = optional.cursor
|
|
136
|
+
? await this.kernel.run((db) => db
|
|
137
|
+
.selectFrom(this.tables.submissions)
|
|
138
|
+
.select(["submitted_at", "id"])
|
|
139
|
+
.where("workspace_id", "=", required.workspaceId)
|
|
140
|
+
.where("id", "=", optional.cursor)
|
|
141
|
+
.limit(1)
|
|
142
|
+
.executeTakeFirst())
|
|
143
|
+
: undefined;
|
|
144
|
+
const rows = await this.kernel.run((db) => {
|
|
145
|
+
let query = db
|
|
146
|
+
.selectFrom(this.tables.submissions)
|
|
147
|
+
.selectAll()
|
|
148
|
+
.where("workspace_id", "=", required.workspaceId)
|
|
149
|
+
.where("form_definition_id", "=", required.formDefinitionId)
|
|
150
|
+
.where("deleted_at", "is", null);
|
|
151
|
+
if (cursorRow) {
|
|
152
|
+
query = query.where((eb) => eb.or([
|
|
153
|
+
eb("submitted_at", "<", cursorRow.submitted_at),
|
|
154
|
+
eb.and([eb("submitted_at", "=", cursorRow.submitted_at), eb("id", "<", cursorRow.id)]),
|
|
155
|
+
]));
|
|
156
|
+
}
|
|
157
|
+
return query.orderBy("submitted_at", "desc").orderBy("id", "desc").limit(required.limit + 1).execute();
|
|
158
|
+
});
|
|
159
|
+
const hasMore = rows.length > required.limit;
|
|
160
|
+
const page = hasMore ? rows.slice(0, required.limit) : rows;
|
|
161
|
+
const nextCursor = hasMore ? page[page.length - 1]?.id ?? null : null;
|
|
162
|
+
return { items: page.map((row) => toSubmissionRecord(row)), nextCursor };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** The form-definition repo for the injected kernel and required host table names.
|
|
166
|
+
* @complexity O(1); no connection or schema work. */
|
|
167
|
+
export function formDefinitionRepoFor({ kernel, tables }, _optional = {}) {
|
|
168
|
+
return new SqlFormDefinitionRepo({ kernel: kernel, tables });
|
|
169
|
+
}
|
|
170
|
+
/** The form-submission repo for the injected kernel and required host table names.
|
|
171
|
+
* @complexity O(1); no connection or schema work. */
|
|
172
|
+
export function formSubmissionRepoFor({ kernel, tables }, _optional = {}) {
|
|
173
|
+
return new SqlFormSubmissionRepo({ kernel: kernel, tables });
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=repo.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Insertable, Selectable } from "kysely";
|
|
2
|
+
import type { FormDefinitionTable, FormSubmissionTable } from "./tables.js";
|
|
3
|
+
import type { FormDefinitionRecord, FormSubmissionRecord } from "../types.js";
|
|
4
|
+
/**
|
|
5
|
+
* @file Row mapping for `form_definitions` / `form_submissions`, shared by every dialect: the columns
|
|
6
|
+
* are the generated `ContentDatabase` types (snake_case, JSON as compact text). Neutral on purpose —
|
|
7
|
+
* no repo, no driver.
|
|
8
|
+
*/
|
|
9
|
+
export type FormDefinitionRow = Selectable<FormDefinitionTable>;
|
|
10
|
+
export type FormSubmissionRow = Selectable<FormSubmissionTable>;
|
|
11
|
+
/** One `form_definitions` row as a {@link FormDefinitionRecord}. */
|
|
12
|
+
export declare function toDefinitionRecord(row: FormDefinitionRow, _optional?: Record<string, never>): FormDefinitionRecord;
|
|
13
|
+
/**
|
|
14
|
+
* The `form_definitions` row an INSERT writes. `version` is included for `create`; `update()` strips
|
|
15
|
+
* it (see {@link updatableDefinitionColumns}) because only the Trash's compare-and-set may move it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function toDefinitionRow(record: FormDefinitionRecord, _optional?: Record<string, never>): Insertable<FormDefinitionTable>;
|
|
18
|
+
/** The columns `update()` writes: everything but the key and `version`. */
|
|
19
|
+
export declare function updatableDefinitionColumns(row: ReturnType<typeof toDefinitionRow>, _optional?: Record<string, never>): {
|
|
20
|
+
name: string;
|
|
21
|
+
slug: string;
|
|
22
|
+
created_at: string;
|
|
23
|
+
updated_at: string;
|
|
24
|
+
fields_json: string;
|
|
25
|
+
notify_json: string;
|
|
26
|
+
status?: string | undefined;
|
|
27
|
+
deleted_at?: string | null;
|
|
28
|
+
};
|
|
29
|
+
/** One `form_submissions` row as a {@link FormSubmissionRecord}. */
|
|
30
|
+
export declare function toSubmissionRecord(row: FormSubmissionRow, _optional?: Record<string, never>): FormSubmissionRecord;
|
|
31
|
+
/** The `form_submissions` row a submit writes (compact JSON data). */
|
|
32
|
+
export declare function toSubmissionRow(record: FormSubmissionRecord, _optional?: Record<string, never>): Insertable<FormSubmissionTable>;
|
|
33
|
+
//# sourceMappingURL=repo.rows.d.ts.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { decodeFormFields, encodeFormFields } from "../fields-json.js";
|
|
2
|
+
/** One `form_definitions` row as a {@link FormDefinitionRecord}. */
|
|
3
|
+
export function toDefinitionRecord(row, _optional = {}) {
|
|
4
|
+
return {
|
|
5
|
+
id: row.id,
|
|
6
|
+
workspaceId: row.workspace_id,
|
|
7
|
+
name: row.name,
|
|
8
|
+
slug: row.slug,
|
|
9
|
+
...decodeFormFields({ json: row.fields_json }),
|
|
10
|
+
notify: JSON.parse(row.notify_json),
|
|
11
|
+
status: row.status,
|
|
12
|
+
createdAt: row.created_at,
|
|
13
|
+
updatedAt: row.updated_at,
|
|
14
|
+
version: row.version,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The `form_definitions` row an INSERT writes. `version` is included for `create`; `update()` strips
|
|
19
|
+
* it (see {@link updatableDefinitionColumns}) because only the Trash's compare-and-set may move it.
|
|
20
|
+
*/
|
|
21
|
+
export function toDefinitionRow(record, _optional = {}) {
|
|
22
|
+
return {
|
|
23
|
+
id: record.id,
|
|
24
|
+
workspace_id: record.workspaceId,
|
|
25
|
+
name: record.name,
|
|
26
|
+
slug: record.slug,
|
|
27
|
+
fields_json: encodeFormFields({ definition: record }),
|
|
28
|
+
notify_json: JSON.stringify(record.notify),
|
|
29
|
+
status: record.status,
|
|
30
|
+
created_at: record.createdAt,
|
|
31
|
+
updated_at: record.updatedAt,
|
|
32
|
+
version: record.version,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** The columns `update()` writes: everything but the key and `version`. */
|
|
36
|
+
export function updatableDefinitionColumns(row, _optional = {}) {
|
|
37
|
+
const { id: _id, workspace_id: _workspaceId, version: _version, ...rest } = row;
|
|
38
|
+
return rest;
|
|
39
|
+
}
|
|
40
|
+
/** One `form_submissions` row as a {@link FormSubmissionRecord}. */
|
|
41
|
+
export function toSubmissionRecord(row, _optional = {}) {
|
|
42
|
+
return {
|
|
43
|
+
id: row.id,
|
|
44
|
+
workspaceId: row.workspace_id,
|
|
45
|
+
formDefinitionId: row.form_definition_id,
|
|
46
|
+
data: JSON.parse(row.data_json),
|
|
47
|
+
// cms-forms <0.2.2 required a string (0.2.2 allows null). Since nullable migration 0006, an
|
|
48
|
+
// expired IP has an empty display value until the host adopts the nullable package DTO.
|
|
49
|
+
sourceIp: row.source_ip ?? "",
|
|
50
|
+
submittedAt: row.submitted_at,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The `form_submissions` row a submit writes (compact JSON data). */
|
|
54
|
+
export function toSubmissionRow(record, _optional = {}) {
|
|
55
|
+
return {
|
|
56
|
+
id: record.id,
|
|
57
|
+
workspace_id: record.workspaceId,
|
|
58
|
+
form_definition_id: record.formDefinitionId,
|
|
59
|
+
data_json: JSON.stringify(record.data),
|
|
60
|
+
source_ip: record.sourceIp,
|
|
61
|
+
submitted_at: record.submittedAt,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=repo.rows.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type StorageKernel } from "@jini-ai/db/kernel";
|
|
2
|
+
import type { FormsTables } from "./tables.js";
|
|
3
|
+
import type { SubmissionIpRetentionPort } from "../submission-ip-retention.js";
|
|
4
|
+
/** Site-owned schema adapter; no retention policy or timer lives here. */
|
|
5
|
+
export declare function createSubmissionIpRetentionRepo<DB>({ kernel: suppliedKernel, tables }: {
|
|
6
|
+
kernel: StorageKernel<DB>;
|
|
7
|
+
tables: FormsTables;
|
|
8
|
+
}, _optional?: Record<string, never>): SubmissionIpRetentionPort;
|
|
9
|
+
//# sourceMappingURL=submission-ip-retention-repo.d.ts.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
import { listColumns } from "@jini-ai/db/kernel";
|
|
3
|
+
/** Site-owned schema adapter; no retention policy or timer lives here. */
|
|
4
|
+
export function createSubmissionIpRetentionRepo({ kernel: suppliedKernel, tables }, _optional = {}) {
|
|
5
|
+
const kernel = suppliedKernel;
|
|
6
|
+
let nullableConfirmed = false;
|
|
7
|
+
return {
|
|
8
|
+
async clearExpiredIps({ submittedBeforeOrAt }, { limit = 500 } = {}) {
|
|
9
|
+
if (!Number.isSafeInteger(limit) || limit <= 0)
|
|
10
|
+
throw new RangeError("submission IP batch limit must be positive");
|
|
11
|
+
if (!nullableConfirmed) {
|
|
12
|
+
// A database not yet at migration 0006 still has NOT NULL source_ip. Fail before
|
|
13
|
+
// writing while that old schema is present; a failed probe is retried on the next pass.
|
|
14
|
+
// The kernel's catalog helper spells the dialect-specific lookup (SQLite table_info,
|
|
15
|
+
// Postgres information_schema), so this adapter stays dialect-neutral.
|
|
16
|
+
const columns = await listColumns(kernel, tables.submissions);
|
|
17
|
+
nullableConfirmed = columns.some(column => column.name === "source_ip" && !column.notNull);
|
|
18
|
+
if (!nullableConfirmed)
|
|
19
|
+
throw new Error("submission IP retention requires the nullable source_ip migration (0006)");
|
|
20
|
+
}
|
|
21
|
+
// Single statement, with the predicate repeated on the write: another process may have
|
|
22
|
+
// cleared a selected row already. NULL→NULL is excluded from the count. No read/modify/
|
|
23
|
+
// save of data_json, no Trash filter, and no lease/outbox event to race or retain the IP.
|
|
24
|
+
// The raw NULL expression bridges the still-published NOT NULL generated table type;
|
|
25
|
+
// the readiness probe above verifies the real schema before issuing it.
|
|
26
|
+
const result = await kernel.run(db => db.updateTable(tables.submissions)
|
|
27
|
+
.set({ source_ip: sql `NULL` })
|
|
28
|
+
.where("source_ip", "is not", null)
|
|
29
|
+
.where("submitted_at", "<=", submittedBeforeOrAt)
|
|
30
|
+
.where("id", "in", db.selectFrom(tables.submissions)
|
|
31
|
+
.select("id")
|
|
32
|
+
.where("source_ip", "is not", null)
|
|
33
|
+
.where("submitted_at", "<=", submittedBeforeOrAt)
|
|
34
|
+
.orderBy("submitted_at").orderBy("id").limit(limit))
|
|
35
|
+
.executeTakeFirst());
|
|
36
|
+
return Number(result.numUpdatedRows);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=submission-ip-retention-repo.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Generated } from "kysely";
|
|
2
|
+
/** Names belong to the host; both repositories borrow their narrow view of its kernel. */
|
|
3
|
+
export interface FormsTables {
|
|
4
|
+
definitions: string;
|
|
5
|
+
submissions: string;
|
|
6
|
+
}
|
|
7
|
+
export interface FormDefinitionTable {
|
|
8
|
+
id: string;
|
|
9
|
+
workspace_id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
slug: string;
|
|
12
|
+
fields_json: string;
|
|
13
|
+
notify_json: string;
|
|
14
|
+
status: Generated<string>;
|
|
15
|
+
created_at: string;
|
|
16
|
+
updated_at: string;
|
|
17
|
+
deleted_at: string | null;
|
|
18
|
+
version: Generated<number>;
|
|
19
|
+
}
|
|
20
|
+
export interface FormSubmissionTable {
|
|
21
|
+
id: string;
|
|
22
|
+
workspace_id: string;
|
|
23
|
+
form_definition_id: string;
|
|
24
|
+
data_json: string;
|
|
25
|
+
source_ip: string | null;
|
|
26
|
+
submitted_at: string;
|
|
27
|
+
deleted_at: string | null;
|
|
28
|
+
version: Generated<number>;
|
|
29
|
+
}
|
|
30
|
+
export type FormDefinitionDatabase = Record<string, FormDefinitionTable>;
|
|
31
|
+
export type FormSubmissionDatabase = Record<string, FormSubmissionTable>;
|
|
32
|
+
//# sourceMappingURL=tables.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `submission-attempts.ts` — remembers, in this process only and for a short window, which
|
|
3
|
+
* public form submissions were just accepted, so a double-clicked Send stores ONE row.
|
|
4
|
+
*
|
|
5
|
+
* Why an attempt token, not the body (2026-10-05, replacing the 2026-10-04 derived-id dedupe): an
|
|
6
|
+
* IP + body key cannot tell a double click from two people behind one office NAT giving the same
|
|
7
|
+
* answer, and deriving the permanent submission id from it left an unsalted IP hash in every row
|
|
8
|
+
* after the 90-day IP sweep. The page now carries a fresh `_attempt` token per response
|
|
9
|
+
* (`injectFormAttemptTokens`, `http/site/form-render.ts`), so a double click repeats one token while
|
|
10
|
+
* two visitors, or the same visitor after a reload, send different ones. Ids are random again.
|
|
11
|
+
*
|
|
12
|
+
* What still shares a token: a static export (the server cannot vary a file per visitor, so it
|
|
13
|
+
* ships none) and a page a shared HTTP cache served to several visitors. Those, and API callers that
|
|
14
|
+
* send no token, fall back to the old IP + body comparison for the same window. Only identical
|
|
15
|
+
* answers from one IP within the window collapse, as before.
|
|
16
|
+
*
|
|
17
|
+
* Why process memory, not a table: the claim must be atomic, short-lived and never persisted (it is
|
|
18
|
+
* an IP-derived hash). One Node process makes check-then-claim atomic with no await between them;
|
|
19
|
+
* a restart or a second machine only forgets recent claims, which stores a duplicate rather than
|
|
20
|
+
* dropping a submission. Same one-process-lifetime shape as the forms rate limiter.
|
|
21
|
+
*/
|
|
22
|
+
import type { SubmitFormInput } from "./index.js";
|
|
23
|
+
/** The hidden field carrying a form's per-response attempt token. Never stored, never validated. */
|
|
24
|
+
export declare const FORM_ATTEMPT_FIELD = "_attempt";
|
|
25
|
+
/** How long after a submission is accepted an identical attempt is answered without a second row. */
|
|
26
|
+
export declare const DUPLICATE_SUBMISSION_WINDOW_MS = 60000;
|
|
27
|
+
/** Returned by {@link SubmissionAttempts.once} when `submit` was skipped as a duplicate. */
|
|
28
|
+
export declare const DUPLICATE_ATTEMPT: unique symbol;
|
|
29
|
+
export interface SubmissionAttempts {
|
|
30
|
+
/** Hash one attempt with the host-supplied hash, without retaining the source IP or body. */
|
|
31
|
+
key(required: {
|
|
32
|
+
input: SubmitFormInput;
|
|
33
|
+
attemptToken: unknown;
|
|
34
|
+
}, optional?: Record<string, never>): string;
|
|
35
|
+
/**
|
|
36
|
+
* Runs `submit` unless the same attempt was accepted within the window. A copy that arrives while
|
|
37
|
+
* the first is still being stored waits for it: answered as a duplicate when the first is
|
|
38
|
+
* accepted, run itself when the first failed (so a failed first copy never swallows its retry).
|
|
39
|
+
* `submit`'s rejection propagates and frees the claim.
|
|
40
|
+
* @complexity O(1) amortized: expired claims are pruned from the oldest end.
|
|
41
|
+
*/
|
|
42
|
+
once<T>(required: {
|
|
43
|
+
key: string;
|
|
44
|
+
nowMs: () => number;
|
|
45
|
+
submit: () => Promise<T>;
|
|
46
|
+
}, optional?: Record<string, never>): Promise<T | typeof DUPLICATE_ATTEMPT>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The key one attempt is remembered under: a SHA-256 of workspace, slug, source IP, attempt token
|
|
50
|
+
* (null when the request carried none) and the body. Body keys are sorted so their order cannot
|
|
51
|
+
* change the key. Held only in memory, only for the window.
|
|
52
|
+
* @complexity O(b log b) for b body keys.
|
|
53
|
+
*/
|
|
54
|
+
export declare function submissionAttemptKey({ input, attemptToken, hash }: {
|
|
55
|
+
input: SubmitFormInput;
|
|
56
|
+
attemptToken: unknown;
|
|
57
|
+
hash: (input: string) => string;
|
|
58
|
+
}, _optional?: Record<string, never>): string;
|
|
59
|
+
/**
|
|
60
|
+
* Builds one in-memory attempt store. Entries sit in claim/acceptance order (an accepted entry is
|
|
61
|
+
* re-inserted at the end), so expired ones are always at the front.
|
|
62
|
+
* @returns The store; see {@link SubmissionAttempts.once}.
|
|
63
|
+
*/
|
|
64
|
+
export declare function createSubmissionAttempts({ hash }: {
|
|
65
|
+
hash: (input: string) => string;
|
|
66
|
+
}, { windowMs, maxEntries }?: {
|
|
67
|
+
windowMs?: number;
|
|
68
|
+
maxEntries?: number;
|
|
69
|
+
}): SubmissionAttempts;
|
|
70
|
+
//# sourceMappingURL=submission-attempts.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** The hidden field carrying a form's per-response attempt token. Never stored, never validated. */
|
|
2
|
+
export const FORM_ATTEMPT_FIELD = "_attempt";
|
|
3
|
+
/** How long after a submission is accepted an identical attempt is answered without a second row. */
|
|
4
|
+
export const DUPLICATE_SUBMISSION_WINDOW_MS = 60_000;
|
|
5
|
+
/** Upper bound on remembered attempts; past it the oldest is forgotten (a duplicate, never a drop). */
|
|
6
|
+
const MAX_REMEMBERED_ATTEMPTS = 10_000;
|
|
7
|
+
/** Returned by {@link SubmissionAttempts.once} when `submit` was skipped as a duplicate. */
|
|
8
|
+
export const DUPLICATE_ATTEMPT = Symbol("duplicate form submission attempt");
|
|
9
|
+
/**
|
|
10
|
+
* The key one attempt is remembered under: a SHA-256 of workspace, slug, source IP, attempt token
|
|
11
|
+
* (null when the request carried none) and the body. Body keys are sorted so their order cannot
|
|
12
|
+
* change the key. Held only in memory, only for the window.
|
|
13
|
+
* @complexity O(b log b) for b body keys.
|
|
14
|
+
*/
|
|
15
|
+
export function submissionAttemptKey({ input, attemptToken, hash }, _optional = {}) {
|
|
16
|
+
// Object keys are unique, so the comparator never sees a tie.
|
|
17
|
+
const body = Object.entries(input.body).sort(([a], [b]) => (a < b ? -1 : 1));
|
|
18
|
+
return hash(JSON.stringify([input.workspaceId, input.slug, input.sourceIp, attemptToken ?? null, body]));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Builds one in-memory attempt store. Entries sit in claim/acceptance order (an accepted entry is
|
|
22
|
+
* re-inserted at the end), so expired ones are always at the front.
|
|
23
|
+
* @returns The store; see {@link SubmissionAttempts.once}.
|
|
24
|
+
*/
|
|
25
|
+
export function createSubmissionAttempts({ hash }, { windowMs = DUPLICATE_SUBMISSION_WINDOW_MS, maxEntries = MAX_REMEMBERED_ATTEMPTS } = {}) {
|
|
26
|
+
const attempts = new Map();
|
|
27
|
+
const prune = (nowMs) => {
|
|
28
|
+
for (const [key, attempt] of attempts) {
|
|
29
|
+
if (attempt.kind === "pending" || nowMs - attempt.atMs < windowMs)
|
|
30
|
+
break;
|
|
31
|
+
attempts.delete(key);
|
|
32
|
+
}
|
|
33
|
+
while (attempts.size >= maxEntries)
|
|
34
|
+
attempts.delete(attempts.keys().next().value);
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
key: (required, optional = {}) => submissionAttemptKey({ ...required, hash }, optional),
|
|
38
|
+
async once({ key, nowMs, submit }, _optional = {}) {
|
|
39
|
+
for (;;) {
|
|
40
|
+
const seen = attempts.get(key);
|
|
41
|
+
if (seen?.kind === "accepted" && nowMs() - seen.atMs < windowMs)
|
|
42
|
+
return DUPLICATE_ATTEMPT;
|
|
43
|
+
if (seen?.kind !== "pending")
|
|
44
|
+
break;
|
|
45
|
+
if (await seen.accepted)
|
|
46
|
+
return DUPLICATE_ATTEMPT;
|
|
47
|
+
}
|
|
48
|
+
// No await between the check above and this claim: one process cannot interleave them.
|
|
49
|
+
prune(nowMs());
|
|
50
|
+
let settle;
|
|
51
|
+
const mine = { kind: "pending", accepted: new Promise((resolve) => { settle = resolve; }) };
|
|
52
|
+
attempts.set(key, mine);
|
|
53
|
+
try {
|
|
54
|
+
const result = await submit();
|
|
55
|
+
if (attempts.get(key) === mine)
|
|
56
|
+
attempts.delete(key);
|
|
57
|
+
attempts.set(key, { kind: "accepted", atMs: nowMs() });
|
|
58
|
+
settle(true);
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (attempts.get(key) === mine)
|
|
63
|
+
attempts.delete(key);
|
|
64
|
+
settle(false);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=submission-attempts.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FormDefinitionRecord } from "./types.js";
|
|
2
|
+
/** Compatibility guard until Jini validates email fields itself. Lookup remains the first step;
|
|
3
|
+
* disabled forms and honeypots retain the package's response before any validation or writes. */
|
|
4
|
+
export declare function assertSubmissionEmails({ definition, body }: {
|
|
5
|
+
definition: FormDefinitionRecord;
|
|
6
|
+
body: Record<string, unknown>;
|
|
7
|
+
}, _optional?: {}): void;
|
|
8
|
+
//# sourceMappingURL=submission-email-validation.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { FormSubmissionValidationError } from "./errors.js";
|
|
2
|
+
import { isHoneypotTripped, validateSubmissionPayload } from "./forms.js";
|
|
3
|
+
// Match native type=email syntax (WHATWG HTML email state), including single-label domains.
|
|
4
|
+
// Do not trim or rewrite answers: Jini's payload contract preserves submitted strings.
|
|
5
|
+
const EMAIL_PATTERN = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
6
|
+
/** Compatibility guard until Jini validates email fields itself. Lookup remains the first step;
|
|
7
|
+
* disabled forms and honeypots retain the package's response before any validation or writes. */
|
|
8
|
+
export function assertSubmissionEmails({ definition, body }, _optional = {}) {
|
|
9
|
+
if (definition.status !== "active" || isHoneypotTripped({ hp: body._hp }))
|
|
10
|
+
return;
|
|
11
|
+
const validation = validateSubmissionPayload({ definition, body });
|
|
12
|
+
const existing = validation.valid ? [] : validation.fieldErrors;
|
|
13
|
+
const invalid = new Set(definition.fields.filter((field) => {
|
|
14
|
+
const value = body[field.id];
|
|
15
|
+
if (field.type !== "email" || typeof value !== "string" || value === "")
|
|
16
|
+
return false;
|
|
17
|
+
if (existing.some((error) => error.field === field.id))
|
|
18
|
+
return false;
|
|
19
|
+
// JS's $ can match before a final newline; comparing the full match refuses that bypass.
|
|
20
|
+
return EMAIL_PATTERN.exec(value)?.[0] !== value;
|
|
21
|
+
}).map((field) => field.id));
|
|
22
|
+
if (invalid.size === 0)
|
|
23
|
+
return;
|
|
24
|
+
const declared = new Set(definition.fields.map((field) => field.id));
|
|
25
|
+
const fieldErrors = existing.filter((error) => !declared.has(error.field));
|
|
26
|
+
for (const field of definition.fields) {
|
|
27
|
+
fieldErrors.push(...existing.filter((error) => error.field === field.id));
|
|
28
|
+
if (invalid.has(field.id))
|
|
29
|
+
fieldErrors.push({ field: field.id, reason: "invalid_email" });
|
|
30
|
+
}
|
|
31
|
+
// The existing localized renderer maps invalid_email to "Please check {label}." in every locale.
|
|
32
|
+
throw new FormSubmissionValidationError({ message: "submission failed field validation", fieldErrors });
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=submission-email-validation.js.map
|
|
@@ -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
|