@beechcms/core 0.9.4 → 0.9.6

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.
Files changed (104) hide show
  1. package/dist/content/content-lock.repository.d.ts +63 -0
  2. package/dist/content/content-lock.repository.d.ts.map +1 -0
  3. package/dist/content/content-lock.repository.js +15 -0
  4. package/dist/content/content.repository.d.ts +22 -22
  5. package/dist/content/content.repository.d.ts.map +1 -1
  6. package/dist/content/retention.d.ts +13 -0
  7. package/dist/content/retention.d.ts.map +1 -0
  8. package/dist/content/retention.js +20 -0
  9. package/dist/content/slug-utils.d.ts +7 -0
  10. package/dist/content/slug-utils.d.ts.map +1 -1
  11. package/dist/content/slug-utils.js +12 -0
  12. package/dist/dashboard-layout/content-view.d.ts +4 -4
  13. package/dist/dashboard-layout/dashboard-layout.d.ts +2 -2
  14. package/dist/dashboard-layout/dashboard-layout.d.ts.map +1 -1
  15. package/dist/dashboard-layout/dashboard-layout.js +3 -3
  16. package/dist/dashboard-layout/kanban/kanban-position.repository.d.ts +19 -0
  17. package/dist/dashboard-layout/kanban/kanban-position.repository.d.ts.map +1 -0
  18. package/dist/dashboard-layout/kanban/kanban-position.repository.js +3 -0
  19. package/dist/dashboard-layout/seed-layout.d.ts.map +1 -1
  20. package/dist/dashboard-layout/seed-layout.js +17 -1
  21. package/dist/engine/ddl/ddl.d.ts +4 -6
  22. package/dist/engine/ddl/ddl.d.ts.map +1 -1
  23. package/dist/engine/ddl/ddl.js +13 -27
  24. package/dist/engine/ddl/seed-ddl-destructive.d.ts +40 -0
  25. package/dist/engine/ddl/seed-ddl-destructive.d.ts.map +1 -0
  26. package/dist/engine/ddl/seed-ddl-destructive.js +149 -0
  27. package/dist/engine/ddl/seed-ddl.d.ts.map +1 -1
  28. package/dist/engine/ddl/seed-ddl.js +10 -2
  29. package/dist/engine/ddl.d.ts +216 -0
  30. package/dist/engine/ddl.d.ts.map +1 -0
  31. package/dist/engine/ddl.js +574 -0
  32. package/dist/engine/define-seed.d.ts +3 -0
  33. package/dist/engine/define-seed.d.ts.map +1 -0
  34. package/dist/engine/define-seed.js +5 -0
  35. package/dist/engine/introspection.d.ts +101 -0
  36. package/dist/engine/introspection.d.ts.map +1 -0
  37. package/dist/engine/introspection.js +127 -0
  38. package/dist/engine/policies.d.ts +58 -0
  39. package/dist/engine/policies.d.ts.map +1 -0
  40. package/dist/engine/policies.js +168 -0
  41. package/dist/engine/privacy.service.d.ts +65 -0
  42. package/dist/engine/privacy.service.d.ts.map +1 -0
  43. package/dist/engine/privacy.service.js +114 -0
  44. package/dist/engine/query/active-clause.d.ts +2 -3
  45. package/dist/engine/query/active-clause.d.ts.map +1 -1
  46. package/dist/engine/query/active-clause.js +3 -4
  47. package/dist/engine/query/query.js +8 -8
  48. package/dist/engine/query/relations.d.ts +15 -0
  49. package/dist/engine/query/relations.d.ts.map +1 -1
  50. package/dist/engine/query.d.ts +23 -0
  51. package/dist/engine/query.d.ts.map +1 -0
  52. package/dist/engine/query.js +248 -0
  53. package/dist/engine/relations.d.ts +28 -0
  54. package/dist/engine/relations.d.ts.map +1 -0
  55. package/dist/engine/relations.js +25 -0
  56. package/dist/engine/schema-fingerprint.d.ts +71 -0
  57. package/dist/engine/schema-fingerprint.d.ts.map +1 -0
  58. package/dist/engine/schema-fingerprint.js +95 -0
  59. package/dist/engine/schema-mutator.d.ts +26 -0
  60. package/dist/engine/schema-mutator.d.ts.map +1 -0
  61. package/dist/engine/schema-mutator.js +3 -0
  62. package/dist/engine/seed-ddl-destructive.d.ts +40 -0
  63. package/dist/engine/seed-ddl-destructive.d.ts.map +1 -0
  64. package/dist/engine/seed-ddl-destructive.js +149 -0
  65. package/dist/engine/seed-ddl.d.ts +43 -0
  66. package/dist/engine/seed-ddl.d.ts.map +1 -0
  67. package/dist/engine/seed-ddl.js +115 -0
  68. package/dist/engine/seed-registry.d.ts +71 -0
  69. package/dist/engine/seed-registry.d.ts.map +1 -0
  70. package/dist/engine/seed-registry.js +167 -0
  71. package/dist/engine/seed-types-generator.d.ts +25 -0
  72. package/dist/engine/seed-types-generator.d.ts.map +1 -0
  73. package/dist/engine/seed-types-generator.js +117 -0
  74. package/dist/engine/seed-validation.d.ts +30 -0
  75. package/dist/engine/seed-validation.d.ts.map +1 -0
  76. package/dist/engine/seed-validation.js +329 -0
  77. package/dist/engine/seeds/seed-definition-diff.d.ts +1 -1
  78. package/dist/engine/seeds/seed-definition-diff.d.ts.map +1 -1
  79. package/dist/engine/seeds/seed-definition-diff.js +2 -2
  80. package/dist/engine/seeds/seed-validation.d.ts.map +1 -1
  81. package/dist/engine/seeds/seed-validation.js +3 -12
  82. package/dist/engine/seeds.d.ts +20 -0
  83. package/dist/engine/seeds.d.ts.map +1 -0
  84. package/dist/engine/seeds.js +21 -0
  85. package/dist/engine/serialize.d.ts +20 -0
  86. package/dist/engine/serialize.d.ts.map +1 -0
  87. package/dist/engine/serialize.js +160 -0
  88. package/dist/engine/types.d.ts +7 -7
  89. package/dist/engine/types.d.ts.map +1 -1
  90. package/dist/index.d.ts +1 -0
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +1 -0
  93. package/dist/notifications/notification-service.d.ts +2 -2
  94. package/dist/notifications/notification.repository.d.ts +6 -3
  95. package/dist/notifications/notification.repository.d.ts.map +1 -1
  96. package/dist/security/time-trap.d.ts +3 -2
  97. package/dist/security/time-trap.d.ts.map +1 -1
  98. package/dist/security/time-trap.js +5 -4
  99. package/dist/seed-types-generator.d.ts +10 -0
  100. package/dist/seed-types-generator.d.ts.map +1 -0
  101. package/dist/seed-types-generator.js +86 -0
  102. package/dist/settings/site-settings.repository.d.ts +2 -0
  103. package/dist/settings/site-settings.repository.d.ts.map +1 -1
  104. package/package.json +2 -2
@@ -0,0 +1 @@
1
+ {"version":3,"file":"introspection.d.ts","sourceRoot":"","sources":["../../src/engine/introspection.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAEtC;;;;;;;GAOG;AACH,MAAM,WAAW,mBAAmB;IAClC,GAAG,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC,CAAA;CAClE;AAED,kEAAkE;AAClE,qBAAa,kBAAmB,SAAQ,KAAK;IACd,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO;gBAAzC,OAAO,EAAE,MAAM,EAAW,KAAK,CAAC,EAAE,OAAO,YAAA;CAItD;AAED,iDAAiD;AACjD,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,4EAA4E;IAC5E,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,EAAE,OAAO,CAAA;IACb,qDAAqD;IACrD,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B;AAED,sDAAsD;AACtD,MAAM,WAAW,cAAc;IAC7B,2CAA2C;IAC3C,MAAM,EAAE,MAAM,CAAA;IACd,6CAA6C;IAC7C,WAAW,EAAE,MAAM,CAAA;IACnB,oCAAoC;IACpC,YAAY,EAAE,MAAM,CAAA;IACpB,oCAAoC;IACpC,QAAQ,EAAE,MAAM,CAAA;IAChB,qCAAqC;IACrC,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,kGAAkG;AAClG,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,OAAO,CAAA;CAChB;AAED,2FAA2F;AAC3F,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,OAAO,CAAA;IACf,sEAAsE;IACtE,OAAO,EAAE,UAAU,EAAE,CAAA;IACrB,wEAAwE;IACxE,WAAW,EAAE,cAAc,EAAE,CAAA;IAC7B,wBAAwB;IACxB,OAAO,EAAE,SAAS,EAAE,CAAA;CACrB;AAED,yEAAyE;AACzE,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,SAAS,EAAE,CAAA;CACpB;AA6CD,mFAAmF;AACnF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOzD;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,SAAS,CAAC,CAgDpB;AAED;;;GAGG;AACH,wBAAsB,UAAU,CAC9B,QAAQ,EAAE,mBAAmB,EAC7B,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,EAAE,CAAC,CAOnB;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,QAAQ,EAAE,mBAAmB,EAC7B,MAAM,CAAC,EAAE,MAAM,EAAE,GAChB,OAAO,CAAC,UAAU,CAAC,CAUrB;AAED;;;;;;GAMG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAAC,IAAI,EAAE,CAAC,CAoBjB"}
@@ -0,0 +1,127 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2024–2026 Flavio De Musso
3
+ /** Thrown when live state cannot be read or cannot be trusted. */
4
+ export class IntrospectionError extends Error {
5
+ cause;
6
+ constructor(message, cause) {
7
+ super(message);
8
+ this.cause = cause;
9
+ this.name = 'IntrospectionError';
10
+ }
11
+ }
12
+ /**
13
+ * SQLite identifiers cannot be bound as parameters, so every table name reaching a PRAGMA is
14
+ * interpolated — and therefore must be proven safe first. Table names in this system are always
15
+ * `content_{slug}` / `rel_{slug}_{alias}` / a structural table, all of which match this pattern.
16
+ */
17
+ const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
18
+ /** Returns `name` unchanged when it is a safe SQL identifier; throws otherwise. */
19
+ export function assertSafeIdentifier(name) {
20
+ if (!SAFE_IDENTIFIER.test(name)) {
21
+ throw new IntrospectionError(`Refusing to introspect '${name}': not a plain SQL identifier. Table names are never user input in BeechCMS.`);
22
+ }
23
+ return name;
24
+ }
25
+ /**
26
+ * Physical state of one table. A table SQLite does not know returns `{ exists: false }` with empty
27
+ * lists rather than throwing: "missing" is a legitimate diff outcome, not an error.
28
+ *
29
+ * `PRAGMA table_info` answers an unknown table with zero rows on the SQLite path and raises on some
30
+ * wrangler paths, so both outcomes collapse to the same result.
31
+ */
32
+ export async function introspectTable(executor, table) {
33
+ const name = assertSafeIdentifier(table);
34
+ const empty = { name, exists: false, columns: [], foreignKeys: [], indexes: [] };
35
+ let info;
36
+ try {
37
+ info = await executor.all(`PRAGMA table_info(${name})`);
38
+ }
39
+ catch {
40
+ return empty;
41
+ }
42
+ if (info.length === 0)
43
+ return empty;
44
+ const columns = info.map(row => ({
45
+ name: row.name,
46
+ sqlType: String(row.type ?? '').toUpperCase(),
47
+ notNull: row.notnull === 1,
48
+ isPk: row.pk > 0,
49
+ defaultValue: row.dflt_value ?? null,
50
+ }));
51
+ let fkRows = [];
52
+ let indexRows = [];
53
+ try {
54
+ fkRows = await executor.all(`PRAGMA foreign_key_list(${name})`);
55
+ indexRows = await executor.all(`PRAGMA index_list(${name})`);
56
+ }
57
+ catch {
58
+ // A table with neither FKs nor indexes answers with zero rows on some drivers and raises on
59
+ // others; an unreadable constraint list must not hide the column list we already have.
60
+ }
61
+ const foreignKeys = fkRows
62
+ .map(row => ({
63
+ column: row.from,
64
+ targetTable: row.table,
65
+ targetColumn: row.to ?? 'id',
66
+ onDelete: String(row.on_delete ?? '').toUpperCase(),
67
+ onUpdate: String(row.on_update ?? '').toUpperCase(),
68
+ }))
69
+ .sort((a, b) => a.column.localeCompare(b.column) || a.targetTable.localeCompare(b.targetTable));
70
+ const indexes = indexRows
71
+ // Implicit indexes SQLite mints for UNIQUE/PK are not schema an author declared, and their
72
+ // names are not stable across environments.
73
+ .filter(row => !row.name.startsWith('sqlite_autoindex_'))
74
+ .map(row => ({ name: row.name, unique: row.unique === 1 }))
75
+ .sort((a, b) => a.name.localeCompare(b.name));
76
+ return { name, exists: true, columns, foreignKeys, indexes };
77
+ }
78
+ /**
79
+ * Names of the physical tables SQLite holds, optionally restricted to a `LIKE` prefix
80
+ * (e.g. `content_`). Sorted, `sqlite_*` internals excluded.
81
+ */
82
+ export async function listTables(executor, prefix) {
83
+ if (prefix !== undefined)
84
+ assertSafeIdentifier(`${prefix}x`);
85
+ const filter = prefix ? ` AND name LIKE '${prefix}%'` : '';
86
+ const rows = await executor.all(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'${filter} ORDER BY name ASC`);
87
+ return rows.map(row => row.name);
88
+ }
89
+ /**
90
+ * Physical state of several tables at once. With no explicit list, every `content_*` table is
91
+ * introspected — the set a schema export or a drift check cares about.
92
+ */
93
+ export async function introspectSchema(executor, tables) {
94
+ const names = tables ?? await listTables(executor, 'content_');
95
+ const sorted = [...names].sort((a, b) => a.localeCompare(b));
96
+ const result = [];
97
+ // Sequential on purpose: the CLI executor is a synchronous shell/SQLite call behind a Promise,
98
+ // and D1 rejects unbounded concurrent statements from one request.
99
+ for (const name of sorted) {
100
+ result.push(await introspectTable(executor, name));
101
+ }
102
+ return { tables: result };
103
+ }
104
+ /**
105
+ * The DECLARED schema: every active seed definition, read live from D1.
106
+ *
107
+ * This — not `beech.schema.ts` — is the source of truth for generated types and for the schema
108
+ * fingerprint (feature brief, business rule 1). The manifest file is a desired-state artifact and
109
+ * may legitimately be ahead of, or behind, what is deployed.
110
+ */
111
+ export async function introspectSeedDefinitions(executor) {
112
+ let rows;
113
+ try {
114
+ rows = await executor.all(`SELECT slug, definition FROM seeds WHERE status = 'active' ORDER BY slug ASC`);
115
+ }
116
+ catch (error) {
117
+ throw new IntrospectionError('Failed to read the `seeds` table from D1.', error);
118
+ }
119
+ return rows.map(row => {
120
+ try {
121
+ return JSON.parse(row.definition);
122
+ }
123
+ catch (error) {
124
+ throw new IntrospectionError(`Seed '${row.slug}' holds a definition that is not valid JSON; D1 state is corrupt.`, error);
125
+ }
126
+ });
127
+ }
@@ -0,0 +1,58 @@
1
+ import type { ActorContext, Branch, DataClassification, Seed } from './types.js';
2
+ /**
3
+ * Computes an un-salted SHA-256 hex digest of a string value.
4
+ * Used for basic digest operations.
5
+ * @param value - The raw string value to hash.
6
+ * @returns A Promise resolving to a 64-character hex string.
7
+ */
8
+ export declare function sha256hex(value: string): Promise<string>;
9
+ /**
10
+ * Verifies if a candidate string matches a stored SHA-256 hash digest.
11
+ * @param stored - The expected 64-character SHA-256 hex digest.
12
+ * @param candidate - The raw candidate string to verify.
13
+ * @returns A Promise resolving to true if candidate matches stored digest, false otherwise.
14
+ */
15
+ export declare function verifyHashField(stored: string, candidate: string): Promise<boolean>;
16
+ /**
17
+ * Resolved classification rules for a branch, bundling storage strategy and API visibility defaults.
18
+ */
19
+ export interface ResolvedClassification {
20
+ /** Canonical data classification tier. */
21
+ classification: DataClassification;
22
+ /** Storage mechanism at rest (`plain`, `encrypt` via AES-GCM, or `hash` via HMAC-SHA256). */
23
+ storage: 'plain' | 'encrypt' | 'hash';
24
+ /** Default visibility rule for unauthenticated public API endpoints (`full` or `hidden`). */
25
+ publicVisibility: 'full' | 'hidden';
26
+ /** Default visibility rule for authenticated API endpoints (`full` or `hidden`). */
27
+ authVisibility: 'full' | 'hidden';
28
+ }
29
+ /** Normalizes raw policy strings to canonical DataClassification enum. */
30
+ export declare function normalizeClassification(raw?: string): DataClassification;
31
+ /**
32
+ * Resolves the 4-tier DataClassification for a branch, bundling its storage
33
+ * strategy at rest and its API visibility rules (public vs auth context).
34
+ * @param branch - The seed branch definition to inspect.
35
+ * @returns The resolved classification details and visibility rules.
36
+ */
37
+ export declare function resolveClassification(branch: Branch): ResolvedClassification;
38
+ /**
39
+ * Resolves branch policies applying all defaults and classification rules.
40
+ * @param branch - The seed branch definition to inspect.
41
+ * @returns Complete required policies object for the branch.
42
+ */
43
+ export declare function resolvePolicies(branch: Branch): Required<NonNullable<Branch['policies']>>;
44
+ /**
45
+ * Filters entry payload fields based on the caller's {@link ActorContext} and branch classification tier.
46
+ *
47
+ * Rules:
48
+ * - Public actor: receives `public` fields; `internal`, `confidential`, and `restricted` fields are omitted.
49
+ * - Authenticated actor: receives `public`, `internal`, and `confidential` fields; `restricted` fields are ALWAYS omitted.
50
+ * - System actor: receives all fields including `restricted` (used for internal worker/automation orchestration).
51
+ *
52
+ * @param data - Raw record fields object.
53
+ * @param seed - Content type seed definition containing branch definitions.
54
+ * @param actor - Context of the caller requesting the entry payload (defaults to authenticated).
55
+ * @returns Filtered data record containing only authorized fields for the given actor context.
56
+ */
57
+ export declare function filterEntryForActor(data: Record<string, unknown>, seed: Seed, actor?: ActorContext): Record<string, unknown>;
58
+ //# sourceMappingURL=policies.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policies.d.ts","sourceRoot":"","sources":["../../src/engine/policies.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAEhF;;;;;GAKG;AACH,wBAAsB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAK9D;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAEzF;AAED;;GAEG;AACH,MAAM,WAAW,sBAAsB;IACrC,0CAA0C;IAC1C,cAAc,EAAE,kBAAkB,CAAA;IAClC,6FAA6F;IAC7F,OAAO,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,CAAA;IACrC,6FAA6F;IAC7F,gBAAgB,EAAE,MAAM,GAAG,QAAQ,CAAA;IACnC,oFAAoF;IACpF,cAAc,EAAE,MAAM,GAAG,QAAQ,CAAA;CAClC;AAED,0EAA0E;AAC1E,wBAAgB,uBAAuB,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,kBAAkB,CAQxE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,sBAAsB,CAmC5E;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,CAAC,WAAW,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CA2BzF;AAID;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,IAAI,EAAE,IAAI,EACV,KAAK,GAAE,YAAwC,GAC9C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAgDzB"}
@@ -0,0 +1,168 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2024–2026 Flavio De Musso
3
+ /**
4
+ * Computes an un-salted SHA-256 hex digest of a string value.
5
+ * Used for basic digest operations.
6
+ * @param value - The raw string value to hash.
7
+ * @returns A Promise resolving to a 64-character hex string.
8
+ */
9
+ export async function sha256hex(value) {
10
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
11
+ return Array.from(new Uint8Array(buf))
12
+ .map((b) => b.toString(16).padStart(2, '0'))
13
+ .join('');
14
+ }
15
+ /**
16
+ * Verifies if a candidate string matches a stored SHA-256 hash digest.
17
+ * @param stored - The expected 64-character SHA-256 hex digest.
18
+ * @param candidate - The raw candidate string to verify.
19
+ * @returns A Promise resolving to true if candidate matches stored digest, false otherwise.
20
+ */
21
+ export async function verifyHashField(stored, candidate) {
22
+ return stored === (await sha256hex(candidate));
23
+ }
24
+ /** Normalizes raw policy strings to canonical DataClassification enum. */
25
+ export function normalizeClassification(raw) {
26
+ if (!raw)
27
+ return 'public';
28
+ const lower = raw.toLowerCase();
29
+ if (lower === 'confidential')
30
+ return 'confidential';
31
+ if (lower === 'restricted' || lower === 'hash')
32
+ return 'restricted';
33
+ if (lower === 'encrypt')
34
+ return 'confidential';
35
+ if (lower === 'internal')
36
+ return 'internal';
37
+ return 'public';
38
+ }
39
+ /**
40
+ * Resolves the 4-tier DataClassification for a branch, bundling its storage
41
+ * strategy at rest and its API visibility rules (public vs auth context).
42
+ * @param branch - The seed branch definition to inspect.
43
+ * @returns The resolved classification details and visibility rules.
44
+ */
45
+ export function resolveClassification(branch) {
46
+ const rawClassification = branch.policies?.classification ?? branch.policies?.privacy;
47
+ const classification = normalizeClassification(rawClassification);
48
+ switch (classification) {
49
+ case 'confidential':
50
+ return {
51
+ classification: 'confidential',
52
+ storage: 'encrypt',
53
+ publicVisibility: 'hidden',
54
+ authVisibility: 'full',
55
+ };
56
+ case 'restricted':
57
+ return {
58
+ classification: 'restricted',
59
+ storage: 'hash',
60
+ publicVisibility: 'hidden',
61
+ authVisibility: 'hidden',
62
+ };
63
+ case 'internal':
64
+ return {
65
+ classification: 'internal',
66
+ storage: 'plain',
67
+ publicVisibility: 'hidden',
68
+ authVisibility: 'full',
69
+ };
70
+ case 'public':
71
+ default:
72
+ return {
73
+ classification: 'public',
74
+ storage: 'plain',
75
+ publicVisibility: 'full',
76
+ authVisibility: 'full',
77
+ };
78
+ }
79
+ }
80
+ /**
81
+ * Resolves branch policies applying all defaults and classification rules.
82
+ * @param branch - The seed branch definition to inspect.
83
+ * @returns Complete required policies object for the branch.
84
+ */
85
+ export function resolvePolicies(branch) {
86
+ const resolved = resolveClassification(branch);
87
+ const isRepeater = branch.type === 'repeater';
88
+ const isEncryptedOrHashed = resolved.storage !== 'plain';
89
+ const defaultVisibility = resolved.authVisibility;
90
+ const isPublicAllowed = branch.policies?.public ?? (resolved.publicVisibility === 'full');
91
+ const defaultFilter = isRepeater || resolved.storage === 'hash' ? false : branch.policies?.filter ?? true;
92
+ const defaultSort = isRepeater || isEncryptedOrHashed ? false : branch.policies?.sort ?? true;
93
+ const defaultSearch = isRepeater || isEncryptedOrHashed ? false : branch.policies?.search ?? true;
94
+ // publicEdit default: true for public fields (unless public: false), false for confidential/internal/restricted
95
+ const defaultPublicEdit = resolved.classification === 'public'
96
+ ? (branch.policies?.publicEdit ?? isPublicAllowed)
97
+ : (branch.policies?.publicEdit ?? false);
98
+ return {
99
+ classification: resolved.classification,
100
+ privacy: resolved.storage,
101
+ visibility: branch.policies?.visibility ?? defaultVisibility,
102
+ search: defaultSearch,
103
+ filter: defaultFilter,
104
+ sort: defaultSort,
105
+ public: isPublicAllowed,
106
+ publicEdit: defaultPublicEdit,
107
+ };
108
+ }
109
+ const SYSTEM_FIELDS = new Set(['id', 'slug', 'status', 'created_at', 'updated_at', 'version', 'has_pending_draft']);
110
+ /**
111
+ * Filters entry payload fields based on the caller's {@link ActorContext} and branch classification tier.
112
+ *
113
+ * Rules:
114
+ * - Public actor: receives `public` fields; `internal`, `confidential`, and `restricted` fields are omitted.
115
+ * - Authenticated actor: receives `public`, `internal`, and `confidential` fields; `restricted` fields are ALWAYS omitted.
116
+ * - System actor: receives all fields including `restricted` (used for internal worker/automation orchestration).
117
+ *
118
+ * @param data - Raw record fields object.
119
+ * @param seed - Content type seed definition containing branch definitions.
120
+ * @param actor - Context of the caller requesting the entry payload (defaults to authenticated).
121
+ * @returns Filtered data record containing only authorized fields for the given actor context.
122
+ */
123
+ export function filterEntryForActor(data, seed, actor = { type: 'authenticated' }) {
124
+ const result = {};
125
+ for (const [key, value] of Object.entries(data)) {
126
+ if (SYSTEM_FIELDS.has(key)) {
127
+ result[key] = value;
128
+ continue;
129
+ }
130
+ const branch = seed.branches.find((b) => b.alias === key);
131
+ if (!branch) {
132
+ // Pass through unknown/extra fields unless actor is public
133
+ if (actor.type !== 'public') {
134
+ result[key] = value;
135
+ }
136
+ continue;
137
+ }
138
+ const resolved = resolveClassification(branch);
139
+ const isPublicActor = actor.type === 'public';
140
+ const isSystemActor = actor.type === 'system';
141
+ // System actor sees all fields including restricted
142
+ if (isSystemActor) {
143
+ result[key] = value;
144
+ continue;
145
+ }
146
+ // Public actor check
147
+ if (isPublicActor) {
148
+ if (resolved.publicVisibility === 'hidden')
149
+ continue;
150
+ }
151
+ else {
152
+ // Authenticated actor check
153
+ if (resolved.authVisibility === 'hidden')
154
+ continue;
155
+ }
156
+ // Explicit visibility override check
157
+ if (branch.policies?.visibility === 'hidden')
158
+ continue;
159
+ // Masking rule check
160
+ if (branch.policies?.visibility === 'masked') {
161
+ result[key] = typeof value === 'string' && value.length > 0 ? '••••••••' : null;
162
+ }
163
+ else {
164
+ result[key] = value;
165
+ }
166
+ }
167
+ return result;
168
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Contract for the Application-Level Encryption (ALE) and hashing privacy service.
3
+ * Used by the Botanical Engine to secure fields classified as `confidential` or `restricted`.
4
+ */
5
+ export interface IPrivacyService {
6
+ /**
7
+ * Symmetrically encrypts a plaintext string using AES-256-GCM.
8
+ * @param plaintext - The raw string value to encrypt.
9
+ * @returns A Promise resolving to formatted ciphertext (`v1:<iv_base64>:<ciphertext_base64>`).
10
+ */
11
+ encrypt(plaintext: string): Promise<string>;
12
+ /**
13
+ * Decrypts a formatted AES-256-GCM ciphertext payload.
14
+ * @param ciphertext - The formatted ciphertext string (`v1:<iv_base64>:<ciphertext_base64>`).
15
+ * @returns A Promise resolving to the original plaintext string.
16
+ * @throws Error if the ciphertext format is invalid or decryption fails (authentication tag mismatch).
17
+ */
18
+ decrypt(ciphertext: string): Promise<string>;
19
+ /**
20
+ * Computes a deterministic HMAC SHA-256 digest of a plaintext string.
21
+ * Used for blind-indexing and one-way field digests (`restricted` classification).
22
+ * @param plaintext - The raw string value to hash.
23
+ * @returns A Promise resolving to a 64-character hexadecimal digest string.
24
+ */
25
+ hash(plaintext: string): Promise<string>;
26
+ }
27
+ /**
28
+ * Concrete Edge-native implementation of {@link IPrivacyService}.
29
+ * Employs Web Crypto API (`crypto.subtle`) for zero-dependency execution in Cloudflare Workers.
30
+ */
31
+ export declare class PrivacyService implements IPrivacyService {
32
+ private readonly masterKey;
33
+ private aesKeyPromise?;
34
+ private hmacKeyPromise?;
35
+ /**
36
+ * Initializes the PrivacyService with a Master Encryption Key.
37
+ * @param masterKey - Secret key used to derive cryptographic keys for AES-GCM and HMAC.
38
+ * @throws Error if `masterKey` is empty or undefined.
39
+ */
40
+ constructor(masterKey: string);
41
+ /**
42
+ * Lazy-loads and caches the derived AES-256-GCM {@link CryptoKey}.
43
+ * Derives a 256-bit key from `masterKey` via SHA-256 digest.
44
+ */
45
+ private getAesKey;
46
+ /**
47
+ * Lazy-loads and caches the derived HMAC SHA-256 {@link CryptoKey}.
48
+ */
49
+ private getHmacKey;
50
+ /**
51
+ * Encrypts a plaintext string with AES-256-GCM and a fresh 96-bit (12-byte) IV.
52
+ * Output is formatted as `v1:<iv_base64>:<ciphertext_base64>`.
53
+ */
54
+ encrypt(plaintext: string): Promise<string>;
55
+ /**
56
+ * Decrypts a `v1:<iv_base64>:<ciphertext_base64>` string back to its plaintext value.
57
+ * If the input string is not prefixed with `v1:`, returns the raw string as unencrypted legacy data.
58
+ */
59
+ decrypt(ciphertext: string): Promise<string>;
60
+ /**
61
+ * Computes a deterministic HMAC SHA-256 hex digest for blind index generation.
62
+ */
63
+ hash(plaintext: string): Promise<string>;
64
+ }
65
+ //# sourceMappingURL=privacy.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"privacy.service.d.ts","sourceRoot":"","sources":["../../src/engine/privacy.service.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;OAIG;IACH,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAE3C;;;;;OAKG;IACH,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAE5C;;;;;OAKG;IACH,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;CACzC;AA4BD;;;GAGG;AACH,qBAAa,cAAe,YAAW,eAAe;IACpD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,aAAa,CAAC,CAAoB;IAC1C,OAAO,CAAC,cAAc,CAAC,CAAoB;IAE3C;;;;OAIG;gBACS,SAAS,EAAE,MAAM;IAO7B;;;OAGG;YACW,SAAS;IAmBvB;;OAEG;YACW,UAAU;IAexB;;;OAGG;IACG,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAcjD;;;OAGG;IACG,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAoBlD;;OAEG;IACG,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAa/C"}
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2024–2026 Flavio De Musso
3
+ /**
4
+ * Converts a Uint8Array byte sequence to a Base64-encoded string.
5
+ * Native Edge-compatible implementation without Node.js Buffer dependencies.
6
+ */
7
+ function bytesToBase64(bytes) {
8
+ let binary = '';
9
+ const len = bytes.byteLength;
10
+ for (let i = 0; i < len; i++) {
11
+ binary += String.fromCharCode(bytes[i]);
12
+ }
13
+ return btoa(binary);
14
+ }
15
+ /**
16
+ * Converts a Base64-encoded string back to a Uint8Array byte sequence.
17
+ * Native Edge-compatible implementation without Node.js Buffer dependencies.
18
+ */
19
+ function base64ToBytes(base64) {
20
+ const binary = atob(base64);
21
+ const bytes = new Uint8Array(binary.length);
22
+ for (let i = 0; i < binary.length; i++) {
23
+ bytes[i] = binary.charCodeAt(i);
24
+ }
25
+ return bytes;
26
+ }
27
+ /**
28
+ * Concrete Edge-native implementation of {@link IPrivacyService}.
29
+ * Employs Web Crypto API (`crypto.subtle`) for zero-dependency execution in Cloudflare Workers.
30
+ */
31
+ export class PrivacyService {
32
+ masterKey;
33
+ aesKeyPromise;
34
+ hmacKeyPromise;
35
+ /**
36
+ * Initializes the PrivacyService with a Master Encryption Key.
37
+ * @param masterKey - Secret key used to derive cryptographic keys for AES-GCM and HMAC.
38
+ * @throws Error if `masterKey` is empty or undefined.
39
+ */
40
+ constructor(masterKey) {
41
+ if (!masterKey) {
42
+ throw new Error('PrivacyService requires a non-empty masterKey');
43
+ }
44
+ this.masterKey = masterKey;
45
+ }
46
+ /**
47
+ * Lazy-loads and caches the derived AES-256-GCM {@link CryptoKey}.
48
+ * Derives a 256-bit key from `masterKey` via SHA-256 digest.
49
+ */
50
+ async getAesKey() {
51
+ if (!this.aesKeyPromise) {
52
+ this.aesKeyPromise = (async () => {
53
+ const keyData = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(this.masterKey));
54
+ return crypto.subtle.importKey('raw', keyData, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
55
+ })();
56
+ }
57
+ return this.aesKeyPromise;
58
+ }
59
+ /**
60
+ * Lazy-loads and caches the derived HMAC SHA-256 {@link CryptoKey}.
61
+ */
62
+ async getHmacKey() {
63
+ if (!this.hmacKeyPromise) {
64
+ this.hmacKeyPromise = (async () => {
65
+ return crypto.subtle.importKey('raw', new TextEncoder().encode(this.masterKey), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
66
+ })();
67
+ }
68
+ return this.hmacKeyPromise;
69
+ }
70
+ /**
71
+ * Encrypts a plaintext string with AES-256-GCM and a fresh 96-bit (12-byte) IV.
72
+ * Output is formatted as `v1:<iv_base64>:<ciphertext_base64>`.
73
+ */
74
+ async encrypt(plaintext) {
75
+ const key = await this.getAesKey();
76
+ const iv = crypto.getRandomValues(new Uint8Array(12));
77
+ const encodedText = new TextEncoder().encode(plaintext);
78
+ const encryptedBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, encodedText);
79
+ const ivBase64 = bytesToBase64(iv);
80
+ const ciphertextBase64 = bytesToBase64(new Uint8Array(encryptedBuffer));
81
+ return `v1:${ivBase64}:${ciphertextBase64}`;
82
+ }
83
+ /**
84
+ * Decrypts a `v1:<iv_base64>:<ciphertext_base64>` string back to its plaintext value.
85
+ * If the input string is not prefixed with `v1:`, returns the raw string as unencrypted legacy data.
86
+ */
87
+ async decrypt(ciphertext) {
88
+ if (!ciphertext || !ciphertext.startsWith('v1:')) {
89
+ return ciphertext;
90
+ }
91
+ const parts = ciphertext.split(':');
92
+ if (parts.length !== 3) {
93
+ throw new Error('Invalid ciphertext format');
94
+ }
95
+ const [, ivBase64, cipherBase64] = parts;
96
+ const iv = base64ToBytes(ivBase64);
97
+ const cipherBytes = base64ToBytes(cipherBase64);
98
+ const key = await this.getAesKey();
99
+ const decryptedBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, cipherBytes);
100
+ return new TextDecoder().decode(decryptedBuffer);
101
+ }
102
+ /**
103
+ * Computes a deterministic HMAC SHA-256 hex digest for blind index generation.
104
+ */
105
+ async hash(plaintext) {
106
+ const key = await this.getHmacKey();
107
+ const encodedText = new TextEncoder().encode(plaintext);
108
+ const signatureBuffer = await crypto.subtle.sign('HMAC', key, encodedText);
109
+ const hashBytes = new Uint8Array(signatureBuffer);
110
+ return Array.from(hashBytes)
111
+ .map((b) => b.toString(16).padStart(2, '0'))
112
+ .join('');
113
+ }
114
+ }
@@ -1,7 +1,6 @@
1
1
  import type { Seed, TrashedMode } from '../types.js';
2
2
  /**
3
- * Returns the SQL condition (e.g. `ce.deleted_at IS NULL`), or `null` if the
4
- * seed does not use soft delete or mode is 'any'.
3
+ * Returns the SQL condition (e.g. `ce.deleted_at IS NULL`), or `null` if mode is 'any'.
5
4
  *
6
5
  * @param seed - The seed schema definition.
7
6
  * @param mode - 'active' (default), 'trashed', or 'any'.
@@ -9,7 +8,7 @@ import type { Seed, TrashedMode } from '../types.js';
9
8
  */
10
9
  export declare function activeCondition(seed: Seed, mode?: TrashedMode, tableAlias?: string): string | null;
11
10
  /**
12
- * Returns ` AND <activeCondition>` for a soft-delete seed, or `''` otherwise.
11
+ * Returns ` AND <activeCondition>`, or `''` when mode is 'any'.
13
12
  *
14
13
  * @param seed - The seed schema definition.
15
14
  * @param mode - 'active' (default), 'trashed', or 'any'.
@@ -1 +1 @@
1
- {"version":3,"file":"active-clause.d.ts","sourceRoot":"","sources":["../../../src/engine/query/active-clause.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAEpD;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,IAAI,EACV,IAAI,GAAE,WAAsB,EAC5B,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,GAAG,IAAI,CAIf;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,IAAI,EACV,IAAI,GAAE,WAAsB,EAC5B,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,CAGR"}
1
+ {"version":3,"file":"active-clause.d.ts","sourceRoot":"","sources":["../../../src/engine/query/active-clause.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAEpD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,IAAI,EACV,IAAI,GAAE,WAAsB,EAC5B,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,GAAG,IAAI,CAIf;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,IAAI,EACV,IAAI,GAAE,WAAsB,EAC5B,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,CAGR"}
@@ -1,21 +1,20 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Copyright (c) 2024–2026 Flavio De Musso
3
3
  /**
4
- * Returns the SQL condition (e.g. `ce.deleted_at IS NULL`), or `null` if the
5
- * seed does not use soft delete or mode is 'any'.
4
+ * Returns the SQL condition (e.g. `ce.deleted_at IS NULL`), or `null` if mode is 'any'.
6
5
  *
7
6
  * @param seed - The seed schema definition.
8
7
  * @param mode - 'active' (default), 'trashed', or 'any'.
9
8
  * @param tableAlias - Optional table or alias qualifier prefix.
10
9
  */
11
10
  export function activeCondition(seed, mode = 'active', tableAlias) {
12
- if (!seed.softDelete || mode === 'any')
11
+ if (mode === 'any')
13
12
  return null;
14
13
  const col = tableAlias ? `${tableAlias}.deleted_at` : 'deleted_at';
15
14
  return mode === 'trashed' ? `${col} IS NOT NULL` : `${col} IS NULL`;
16
15
  }
17
16
  /**
18
- * Returns ` AND <activeCondition>` for a soft-delete seed, or `''` otherwise.
17
+ * Returns ` AND <activeCondition>`, or `''` when mode is 'any'.
19
18
  *
20
19
  * @param seed - The seed schema definition.
21
20
  * @param mode - 'active' (default), 'trashed', or 'any'.
@@ -112,7 +112,7 @@ export function buildSelectQuery(seed, options = {}) {
112
112
  joinClause = `LEFT JOIN kanban_positions kp` +
113
113
  ` ON kp.seed_slug = ? AND kp.entry_id = ${table}.id AND kp.axis_branch_id = ?`;
114
114
  bindings.push(options.kanbanOrder.seedSlug, options.kanbanOrder.axisBranchId);
115
- kanbanOrderClause = ` ORDER BY (kp.position IS NULL) ASC, kp.position ASC`;
115
+ kanbanOrderClause = ` ORDER BY (kp.position IS NULL) ASC, kp.position ASC, ${table}.id ASC`;
116
116
  }
117
117
  const rtBranches = indexableSearchBranches(seed);
118
118
  if (search && rtBranches.length > 0) {
@@ -128,12 +128,10 @@ export function buildSelectQuery(seed, options = {}) {
128
128
  // Soft delete: 'active' is the default, so a caller that forgets the option can never
129
129
  // observe a trashed row — including the Public API, relation expansion and subqueries,
130
130
  // which all reach SQL only through repository.findMany.
131
- if (seed.softDelete) {
132
- const trashed = options.trashed ?? 'active';
133
- const cond = activeCondition(seed, trashed, table);
134
- if (cond) {
135
- whereClauses.push(cond);
136
- }
131
+ const trashed = options.trashed ?? 'active';
132
+ const cond = activeCondition(seed, trashed, table);
133
+ if (cond) {
134
+ whereClauses.push(cond);
137
135
  }
138
136
  const groupClauses = [];
139
137
  for (const group of filters) {
@@ -202,9 +200,11 @@ export function buildSelectQuery(seed, options = {}) {
202
200
  sql += orderedBranch && isSingleChoiceTags(orderedBranch)
203
201
  ? tagsOrderClause(col, orderedBranch, dir, bindings)
204
202
  : ` ORDER BY ${col} ${dir}`;
203
+ // Unique final key: tied sort values would otherwise page in an undefined order.
204
+ sql += `, ${table}.id ${dir}`;
205
205
  }
206
206
  else {
207
- sql += ` ORDER BY ${table}.created_at DESC`;
207
+ sql += ` ORDER BY ${table}.created_at DESC, ${table}.id DESC`;
208
208
  }
209
209
  if (pagination) {
210
210
  sql += ` LIMIT ? OFFSET ?`;
@@ -18,6 +18,21 @@ export interface BackrefSource {
18
18
  /** True when branch.onDelete === 'RESTRICT' — used to disable Delete button */
19
19
  restricts: boolean;
20
20
  }
21
+ export interface BackrefItem {
22
+ id: string;
23
+ displayName: string | null;
24
+ status: string;
25
+ updated_at: number | null;
26
+ }
27
+ export interface QueryGroupResult {
28
+ items: BackrefItem[];
29
+ total: number;
30
+ }
31
+ /** Port for reading back-references (entries pointing at a target entry). */
32
+ export interface IBackrefRepository {
33
+ entryExists(seedOrSlug: Seed | string, id: string): Promise<boolean>;
34
+ queryGroup(source: BackrefSource, sourceSeed: Seed, targetId: string, limit: number, offset: number): Promise<QueryGroupResult>;
35
+ }
21
36
  /** Map: targetSlug → every source that points to it */
22
37
  export type BackrefMap = Map<string, BackrefSource[]>;
23
38
  /**