@beechcms/core 0.4.0-preview.1 → 0.4.0-preview.10

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/src/engine.ts DELETED
@@ -1,465 +0,0 @@
1
- /**
2
- * Botanical Engine: Schema Compiler + Query Builder.
3
- *
4
- * In v0.4.0 ogni Seed ha una tabella SQL dedicata (`content_{slug}`) con colonne
5
- * reali tipizzate. Questo modulo genera il DDL e costruisce query parametrizzate.
6
- * Non conosce HTTP, auth o UI — è una libreria Node.js pura (C6).
7
- */
8
- import type {
9
- Seed,
10
- Branch,
11
- BranchType,
12
- FilterGroup,
13
- FilterType,
14
- FilterCondition,
15
- SelectOptions,
16
- ParameterizedQuery,
17
- } from './types.js'
18
-
19
- // ---- SQL type mapping ----
20
-
21
- interface BranchSqlDef {
22
- sqlType: 'TEXT' | 'REAL' | 'INTEGER'
23
- }
24
-
25
- const BRANCH_TYPE_SQL: Record<BranchType, BranchSqlDef> = {
26
- text: { sqlType: 'TEXT' },
27
- number: { sqlType: 'REAL' },
28
- boolean: { sqlType: 'INTEGER' },
29
- date: { sqlType: 'INTEGER' }, // Unix timestamp (seconds)
30
- json: { sqlType: 'TEXT' }, // JSON serializzato
31
- richtext: { sqlType: 'TEXT' },
32
- file: { sqlType: 'TEXT' }, // URL singolo o JSON array di URL
33
- }
34
-
35
- const SYSTEM_COLUMNS = new Set(['id', 'slug', 'status', 'created_at', 'updated_at'])
36
-
37
- // ---- Private helpers ----
38
-
39
- function tableName(seed: Seed): string {
40
- return `content_${seed.slug}`
41
- }
42
-
43
- function ftsTableName(seed: Seed): string {
44
- return `fts_${seed.slug}`
45
- }
46
-
47
- function isValidColumn(seed: Seed, col: string): boolean {
48
- if (SYSTEM_COLUMNS.has(col)) return true
49
- return seed.branches.some(b => b.alias === col)
50
- }
51
-
52
- function indexableSearchBranches(seed: Seed): Branch[] {
53
- return seed.branches.filter(b =>
54
- (b.type === 'text' || b.type === 'richtext') && b.policies?.search !== false
55
- )
56
- }
57
-
58
- function isAssetListBranch(branch: Branch): boolean {
59
- return branch.type === 'file' && (branch.multiple === true || branch.format === 'asset-list')
60
- }
61
-
62
- function normalizeHttpUrl(value: unknown): string | null {
63
- if (typeof value !== 'string') return null
64
- const cleaned = value.trim()
65
- if (!cleaned) return null
66
- try {
67
- const parsed = new URL(cleaned)
68
- return parsed.protocol.startsWith('http') ? cleaned : null
69
- } catch {
70
- return null
71
- }
72
- }
73
-
74
- function parseJsonSafe(value: string): unknown {
75
- try { return JSON.parse(value) } catch { return value }
76
- }
77
-
78
- function normalizeAssetListValue(rawValue: unknown): string[] {
79
- const input = typeof rawValue === 'string' ? parseJsonSafe(rawValue) : rawValue
80
- const values = Array.isArray(input) ? input : [input]
81
- const normalized: string[] = []
82
- for (const item of values) {
83
- if (item == null) continue
84
- const direct = normalizeHttpUrl(item)
85
- if (direct) { normalized.push(direct); continue }
86
- if (typeof item === 'object' && !Array.isArray(item)) {
87
- const fromObj = normalizeHttpUrl((item as Record<string, unknown>).url)
88
- if (fromObj) normalized.push(fromObj)
89
- }
90
- }
91
- return [...new Set(normalized)]
92
- }
93
-
94
- // ---- DDL Generators ----
95
-
96
- /**
97
- * Genera `CREATE TABLE IF NOT EXISTS content_{slug}` con colonne di sistema
98
- * + una colonna per ogni Branch. Funzione pura: stesso Seed → stesso SQL.
99
- */
100
- export function generateCreateTable(seed: Seed): string {
101
- const table = tableName(seed)
102
- const lines: string[] = [
103
- `CREATE TABLE IF NOT EXISTS ${table} (`,
104
- ` id TEXT NOT NULL PRIMARY KEY,`,
105
- ` slug TEXT NOT NULL UNIQUE,`,
106
- ` status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'review', 'published', 'archived')),`,
107
- ]
108
-
109
- for (const branch of seed.branches) {
110
- const { sqlType } = BRANCH_TYPE_SQL[branch.type]
111
- let col = ` ${branch.alias} ${sqlType}`
112
- if (branch.requiredOnCreate) col += ' NOT NULL'
113
- if (branch.type === 'boolean') col += ` CHECK (${branch.alias} IN (0, 1))`
114
- lines.push(col + ',')
115
- }
116
-
117
- lines.push(` created_at INTEGER NOT NULL DEFAULT (unixepoch()),`)
118
- lines.push(` updated_at INTEGER NOT NULL DEFAULT (unixepoch())`)
119
- lines.push(`);`)
120
-
121
- return lines.join('\n')
122
- }
123
-
124
- /**
125
- * Genera la tabella bozze `content_{slug}_drafts` per i Seed con `allowDrafts: true`.
126
- * Tutte le colonne branch sono nullable (le bozze sono parziali).
127
- * Ritorna null se il Seed non ha `allowDrafts: true`.
128
- */
129
- export function generateDraftTable(seed: Seed): string | null {
130
- if (!seed.allowDrafts) return null
131
-
132
- const table = `content_${seed.slug}_drafts`
133
- const mainTable = `content_${seed.slug}`
134
- const lines: string[] = [
135
- `CREATE TABLE IF NOT EXISTS ${table} (`,
136
- ` entry_id TEXT NOT NULL PRIMARY KEY`,
137
- ` REFERENCES ${mainTable}(id) ON DELETE CASCADE,`,
138
- ]
139
-
140
- for (const branch of seed.branches) {
141
- const { sqlType } = BRANCH_TYPE_SQL[branch.type]
142
- let col = ` ${branch.alias} ${sqlType}`
143
- // boolean CHECK: in SQLite, NULL IN (0,1) → NULL, che passa il CHECK (solo FALSE lo fallisce)
144
- if (branch.type === 'boolean') col += ` CHECK (${branch.alias} IN (0, 1))`
145
- lines.push(col + ',')
146
- }
147
-
148
- lines.push(` updated_at INTEGER NOT NULL DEFAULT (unixepoch())`)
149
- lines.push(`);`)
150
-
151
- return lines.join('\n')
152
- }
153
-
154
- /**
155
- * Genera `ALTER TABLE content_{slug} ADD COLUMN {alias} {type}`.
156
- * Nuove colonne sono sempre nullable (limite SQLite su ALTER TABLE).
157
- */
158
- export function generateAddColumn(seed: Seed, branch: Branch): string {
159
- const { sqlType } = BRANCH_TYPE_SQL[branch.type]
160
- return `ALTER TABLE ${tableName(seed)} ADD COLUMN ${branch.alias} ${sqlType};`
161
- }
162
-
163
- /**
164
- * Genera indici B-tree per status, created_at e ogni Branch filtrabile
165
- * con tipo indicizzabile (text, number, date, boolean).
166
- */
167
- export function generateIndexes(seed: Seed): string[] {
168
- const table = tableName(seed)
169
- const slug = seed.slug
170
- const indexes: string[] = [
171
- `CREATE INDEX IF NOT EXISTS idx_${slug}_status ON ${table}(status);`,
172
- `CREATE INDEX IF NOT EXISTS idx_${slug}_created_at ON ${table}(created_at);`,
173
- ]
174
-
175
- for (const branch of seed.branches) {
176
- if (branch.policies?.filter === false) continue
177
- if (['text', 'number', 'date', 'boolean'].includes(branch.type)) {
178
- indexes.push(
179
- `CREATE INDEX IF NOT EXISTS idx_${slug}_${branch.alias} ON ${table}(${branch.alias});`
180
- )
181
- }
182
- }
183
-
184
- return indexes
185
- }
186
-
187
- /**
188
- * Genera la virtual table FTS5 per i Branch text/richtext indicizzabili.
189
- * Ritorna null se il Seed non ha branch con search abilitato.
190
- */
191
- export function generateFtsTable(seed: Seed): string | null {
192
- const rtBranches = indexableSearchBranches(seed)
193
- if (rtBranches.length === 0) return null
194
-
195
- const ftsTable = ftsTableName(seed)
196
- const cols = rtBranches.map(b => ` ${b.alias}`).join(',\n')
197
-
198
- return [
199
- `CREATE VIRTUAL TABLE IF NOT EXISTS ${ftsTable} USING fts5(`,
200
- ` entry_id UNINDEXED,`,
201
- `${cols},`,
202
- ` tokenize = 'unicode61'`,
203
- `);`,
204
- ].join('\n')
205
- }
206
-
207
- /**
208
- * Genera i 3 trigger SQLite (insert/update/delete) che mantengono la FTS
209
- * sincronizzata automaticamente — elimina la necessità di syncFts manuale.
210
- * Ritorna array vuoto se il Seed non ha branch indicizzabili.
211
- */
212
- export function generateFtsTriggers(seed: Seed): string[] {
213
- const rtBranches = indexableSearchBranches(seed)
214
- if (rtBranches.length === 0) return []
215
-
216
- const table = tableName(seed)
217
- const ftsTable = ftsTableName(seed)
218
- const slug = seed.slug
219
- const cols = rtBranches.map(b => b.alias)
220
- const ftsColList = ['entry_id', ...cols].join(', ')
221
- const newValList = ['new.id', ...cols.map(c => `new.${c}`)].join(', ')
222
-
223
- return [
224
- [
225
- `CREATE TRIGGER IF NOT EXISTS fts_${slug}_insert`,
226
- `AFTER INSERT ON ${table} BEGIN`,
227
- ` INSERT INTO ${ftsTable}(${ftsColList}) VALUES (${newValList});`,
228
- `END;`,
229
- ].join('\n'),
230
- [
231
- `CREATE TRIGGER IF NOT EXISTS fts_${slug}_update`,
232
- `AFTER UPDATE OF ${cols.join(', ')} ON ${table} BEGIN`,
233
- ` DELETE FROM ${ftsTable} WHERE entry_id = old.id;`,
234
- ` INSERT INTO ${ftsTable}(${ftsColList}) VALUES (${newValList});`,
235
- `END;`,
236
- ].join('\n'),
237
- [
238
- `CREATE TRIGGER IF NOT EXISTS fts_${slug}_delete`,
239
- `AFTER DELETE ON ${table} BEGIN`,
240
- ` DELETE FROM ${ftsTable} WHERE entry_id = old.id;`,
241
- `END;`,
242
- ].join('\n'),
243
- ]
244
- }
245
-
246
- // ---- Query Builder ----
247
-
248
- /**
249
- * Costruisce una SELECT parametrizzata su `content_{slug}`.
250
- * Non usa mai json_extract — ogni colonna è una colonna reale.
251
- * Colonne sconosciute nei filtri/orderBy vengono ignorate (fail-closed).
252
- */
253
- export function buildSelectQuery(seed: Seed, options: SelectOptions = {}): ParameterizedQuery {
254
- const table = tableName(seed)
255
- const { filters = [], orderBy, pagination, status, search, fields } = options
256
- const bindings: (string | number | boolean | null)[] = []
257
- const whereClauses: string[] = []
258
- let joinClause = ''
259
-
260
- // FTS JOIN — solo se il seed ha branch indicizzabili
261
- const rtBranches = indexableSearchBranches(seed)
262
- if (search && rtBranches.length > 0) {
263
- const ftsTable = ftsTableName(seed)
264
- joinClause = `INNER JOIN ${ftsTable} ON ${ftsTable}.entry_id = ${table}.id`
265
- whereClauses.push(`${ftsTable} MATCH ?`)
266
- // FTS5: prefix match con quote per caratteri speciali
267
- bindings.push(`"${search.replace(/"/g, '""')}"*`)
268
- }
269
-
270
- // Filtro status
271
- if (status !== undefined && status !== null) {
272
- whereClauses.push(`${table}.status = ?`)
273
- bindings.push(status)
274
- }
275
-
276
- // Filtri utente
277
- for (const group of filters) {
278
- if (!isValidColumn(seed, group.column)) continue
279
- const col = SYSTEM_COLUMNS.has(group.column)
280
- ? `${table}.${group.column}`
281
- : group.column
282
-
283
- for (const cond of group.conditions) {
284
- const clause = buildFilterCondition(col, group.type, cond, bindings)
285
- if (clause) whereClauses.push(clause)
286
- }
287
- }
288
-
289
- // Proiezione colonne
290
- let selectCols = `${table}.*`
291
- if (fields && fields.length > 0) {
292
- const valid = fields.filter(f => isValidColumn(seed, f))
293
- if (valid.length > 0) {
294
- selectCols = valid
295
- .map(f => (SYSTEM_COLUMNS.has(f) ? `${table}.${f}` : f))
296
- .join(', ')
297
- }
298
- }
299
-
300
- let sql = `SELECT ${selectCols} FROM ${table}`
301
- if (joinClause) sql += ` ${joinClause}`
302
- if (whereClauses.length > 0) sql += ` WHERE ${whereClauses.join(' AND ')}`
303
-
304
- // ORDER BY
305
- if (orderBy && isValidColumn(seed, orderBy.column)) {
306
- const dir = orderBy.dir === 'DESC' ? 'DESC' : 'ASC'
307
- const col = SYSTEM_COLUMNS.has(orderBy.column)
308
- ? `${table}.${orderBy.column}`
309
- : orderBy.column
310
- sql += ` ORDER BY ${col} ${dir}`
311
- } else {
312
- sql += ` ORDER BY ${table}.created_at DESC`
313
- }
314
-
315
- // Paginazione
316
- if (pagination) {
317
- sql += ` LIMIT ? OFFSET ?`
318
- bindings.push(pagination.limit, pagination.offset)
319
- }
320
-
321
- return { sql, bindings }
322
- }
323
-
324
- function buildFilterCondition(
325
- col: string,
326
- type: FilterType,
327
- cond: FilterCondition,
328
- bindings: (string | number | boolean | null)[]
329
- ): string | null {
330
- const { op, value } = cond
331
-
332
- if (op === 'is_empty') {
333
- return type === 'text' ? `(${col} IS NULL OR ${col} = '')` : `${col} IS NULL`
334
- }
335
- if (op === 'is_not_empty') {
336
- return type === 'text' ? `(${col} IS NOT NULL AND ${col} != '')` : `${col} IS NOT NULL`
337
- }
338
- if (value === null || value === undefined) return null
339
-
340
- if (op === 'eq') {
341
- bindings.push(type === 'boolean' ? (value ? 1 : 0) : (value as string | number))
342
- return `${col} = ?`
343
- }
344
- if (op === 'contains') {
345
- if (type === 'tags') {
346
- bindings.push(String(value))
347
- return `EXISTS (SELECT 1 FROM json_each(${col}) WHERE value = ?)`
348
- }
349
- bindings.push(`%${String(value)}%`)
350
- return `${col} LIKE ?`
351
- }
352
-
353
- const mathOps: Record<string, string> = { gt: '>', gte: '>=', lt: '<', lte: '<=' }
354
- if (mathOps[op]) {
355
- bindings.push(value as number)
356
- return `${col} ${mathOps[op]} ?`
357
- }
358
-
359
- return null
360
- }
361
-
362
- // ---- Schema introspection ----
363
-
364
- export interface SchemaColumn {
365
- name: string
366
- sqlType: 'TEXT' | 'REAL' | 'INTEGER'
367
- notNull: boolean
368
- isPk: boolean
369
- }
370
-
371
- /**
372
- * Ritorna la lista di colonne attese per la tabella di un Seed.
373
- * Usato da `beech seed:load --diff` per confrontare schema attuale vs atteso.
374
- */
375
- export function getExpectedColumns(seed: Seed): SchemaColumn[] {
376
- return [
377
- { name: 'id', sqlType: 'TEXT', notNull: true, isPk: true },
378
- { name: 'slug', sqlType: 'TEXT', notNull: true, isPk: false },
379
- { name: 'status', sqlType: 'TEXT', notNull: true, isPk: false },
380
- ...seed.branches.map(b => ({
381
- name: b.alias,
382
- sqlType: BRANCH_TYPE_SQL[b.type].sqlType,
383
- notNull: b.requiredOnCreate ?? false,
384
- isPk: false,
385
- })),
386
- { name: 'created_at', sqlType: 'INTEGER', notNull: true, isPk: false },
387
- { name: 'updated_at', sqlType: 'INTEGER', notNull: true, isPk: false },
388
- ]
389
- }
390
-
391
- // ---- Serialization / Deserialization ----
392
-
393
- /**
394
- * Serializza un valore per la scrittura nel DB.
395
- * boolean → 0/1 | date → Unix timestamp | json/asset-list → JSON string
396
- */
397
- export function serializeForDb(branch: Branch, value: unknown): string | number | null {
398
- if (value === null || value === undefined) return null
399
-
400
- switch (branch.type) {
401
- case 'boolean':
402
- return value ? 1 : 0
403
-
404
- case 'json':
405
- case 'richtext':
406
- return typeof value === 'string' ? value : JSON.stringify(value)
407
-
408
- case 'date': {
409
- if (typeof value === 'number') return value
410
- if (typeof value === 'string') {
411
- const d = new Date(value)
412
- if (isNaN(d.getTime())) return null
413
- if (branch.format === 'date') {
414
- const midnight = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
415
- return Math.floor(midnight / 1000)
416
- }
417
- return Math.floor(d.getTime() / 1000)
418
- }
419
- return null
420
- }
421
-
422
- case 'file':
423
- if (isAssetListBranch(branch)) {
424
- return Array.isArray(value) ? JSON.stringify(value) : typeof value === 'string' ? value : null
425
- }
426
- return typeof value === 'string' ? value : null
427
-
428
- default:
429
- return typeof value === 'string' ? value : typeof value === 'number' ? value : null
430
- }
431
- }
432
-
433
- /**
434
- * Deserializza un valore letto dal DB per la risposta API.
435
- * 0/1 → boolean | Unix timestamp → ISO 8601 | JSON string → object
436
- */
437
- export function deserializeFromDb(branch: Branch, value: unknown): unknown {
438
- if (value === null || value === undefined) return null
439
-
440
- switch (branch.type) {
441
- case 'boolean':
442
- return value === 1 || value === true
443
-
444
- case 'json':
445
- case 'richtext': {
446
- if (typeof value === 'string') {
447
- try { return JSON.parse(value) } catch { return value }
448
- }
449
- return value
450
- }
451
-
452
- case 'date': {
453
- if (typeof value !== 'number') return null
454
- const d = new Date(value * 1000)
455
- return branch.format === 'date' ? d.toISOString().slice(0, 10) : d.toISOString()
456
- }
457
-
458
- case 'file':
459
- if (isAssetListBranch(branch)) return normalizeAssetListValue(value)
460
- return typeof value === 'string' ? value : null
461
-
462
- default:
463
- return value
464
- }
465
- }
package/src/index.ts DELETED
@@ -1,19 +0,0 @@
1
- /**
2
- * @beechcms/core - Botanical Engine
3
- *
4
- * Pacchetto condiviso del monorepo Beech CMS.
5
- * In v0.4.0 il Botanical Engine è un compilatore di schema SQL: legge i Seed
6
- * TypeScript e genera DDL deterministico + query parametrizzate.
7
- *
8
- * @module @beechcms/core
9
- */
10
-
11
- export * from './types.js'
12
- export * from './define-seed.js'
13
- export * from './seeds.js'
14
- export * from './engine.js'
15
- export * from './validation.js'
16
- export * from './richtext.js'
17
- export * from './richtext-render.js'
18
- export * from './slug-utils.js'
19
- export * from './policies.js'
@@ -1,127 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { resolvePolicies, verifyHashField } from './policies'
3
- import type { Branch } from './types'
4
-
5
- const baseBranch: Branch = {
6
- id: 'br_01',
7
- alias: 'field',
8
- label: 'Field',
9
- type: 'text',
10
- }
11
-
12
- describe('resolvePolicies', () => {
13
- it('returns all defaults when policies is undefined', () => {
14
- const result = resolvePolicies(baseBranch)
15
- expect(result).toEqual({
16
- privacy: 'plain',
17
- visibility: 'full',
18
- search: true,
19
- filter: true,
20
- sort: true,
21
- public: true,
22
- })
23
- })
24
-
25
- it('returns all defaults when policies is an empty object', () => {
26
- const result = resolvePolicies({ ...baseBranch, policies: {} })
27
- expect(result).toEqual({
28
- privacy: 'plain',
29
- visibility: 'full',
30
- search: true,
31
- filter: true,
32
- sort: true,
33
- public: true,
34
- })
35
- })
36
-
37
- it('privacy: hash defaults visibility to hidden', () => {
38
- const result = resolvePolicies({ ...baseBranch, policies: { privacy: 'hash' } })
39
- expect(result.privacy).toBe('hash')
40
- expect(result.visibility).toBe('hidden')
41
- expect(result.search).toBe(true)
42
- expect(result.filter).toBe(true)
43
- expect(result.sort).toBe(true)
44
- expect(result.public).toBe(true)
45
- })
46
-
47
- it('privacy: hash with explicit visibility overrides the default', () => {
48
- const result = resolvePolicies({ ...baseBranch, policies: { privacy: 'hash', visibility: 'masked' } })
49
- expect(result.privacy).toBe('hash')
50
- expect(result.visibility).toBe('masked')
51
- })
52
-
53
- it('overrides visibility independently without affecting other defaults', () => {
54
- const result = resolvePolicies({ ...baseBranch, policies: { visibility: 'masked' } })
55
- expect(result.visibility).toBe('masked')
56
- expect(result.privacy).toBe('plain')
57
- expect(result.search).toBe(true)
58
- })
59
-
60
- it('overrides search: false independently', () => {
61
- const result = resolvePolicies({ ...baseBranch, policies: { search: false } })
62
- expect(result.search).toBe(false)
63
- expect(result.filter).toBe(true)
64
- expect(result.sort).toBe(true)
65
- expect(result.public).toBe(true)
66
- })
67
-
68
- it('overrides filter: false independently', () => {
69
- const result = resolvePolicies({ ...baseBranch, policies: { filter: false } })
70
- expect(result.filter).toBe(false)
71
- expect(result.search).toBe(true)
72
- expect(result.sort).toBe(true)
73
- })
74
-
75
- it('overrides sort: false independently', () => {
76
- const result = resolvePolicies({ ...baseBranch, policies: { sort: false } })
77
- expect(result.sort).toBe(false)
78
- expect(result.filter).toBe(true)
79
- expect(result.search).toBe(true)
80
- })
81
-
82
- it('overrides public: false independently', () => {
83
- const result = resolvePolicies({ ...baseBranch, policies: { public: false } })
84
- expect(result.public).toBe(false)
85
- expect(result.visibility).toBe('full')
86
- })
87
-
88
- it('handles multiple overrides simultaneously', () => {
89
- const result = resolvePolicies({
90
- ...baseBranch,
91
- policies: { privacy: 'hash', visibility: 'hidden', search: false, public: false },
92
- })
93
- expect(result.privacy).toBe('hash')
94
- expect(result.visibility).toBe('hidden')
95
- expect(result.search).toBe(false)
96
- expect(result.filter).toBe(true)
97
- expect(result.sort).toBe(true)
98
- expect(result.public).toBe(false)
99
- })
100
-
101
- it('handles visibility: hidden correctly', () => {
102
- const result = resolvePolicies({ ...baseBranch, policies: { visibility: 'hidden' } })
103
- expect(result.visibility).toBe('hidden')
104
- })
105
-
106
- it('privacy: encrypt defaults visibility to hidden', () => {
107
- const result = resolvePolicies({ ...baseBranch, policies: { privacy: 'encrypt' } })
108
- expect(result.privacy).toBe('encrypt')
109
- expect(result.visibility).toBe('hidden')
110
- })
111
- })
112
-
113
- describe('verifyHashField', () => {
114
- it('returns true when candidate matches stored hash', async () => {
115
- const candidate = 'mysecretpassword'
116
- // pre-compute the expected hash
117
- const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(candidate))
118
- const stored = Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('')
119
- expect(await verifyHashField(stored, candidate)).toBe(true)
120
- })
121
-
122
- it('returns false when candidate does not match stored hash', async () => {
123
- const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('correct'))
124
- const stored = Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('')
125
- expect(await verifyHashField(stored, 'wrong')).toBe(false)
126
- })
127
- })
package/src/policies.ts DELETED
@@ -1,32 +0,0 @@
1
- import type { Branch } from './types.js'
2
-
3
- export async function sha256hex(value: string): Promise<string> {
4
- const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
5
- return Array.from(new Uint8Array(buf))
6
- .map((b) => b.toString(16).padStart(2, '0'))
7
- .join('')
8
- }
9
-
10
- export async function verifyHashField(stored: string, candidate: string): Promise<boolean> {
11
- return stored === (await sha256hex(candidate))
12
- }
13
-
14
- /**
15
- * Risolve le policy di un branch applicando i valori di default.
16
- * Tutta la logica di accesso ai campi deve passare per questa funzione,
17
- * mai con inline `branch.policies?.x ?? default`.
18
- */
19
- export function resolvePolicies(branch: Branch): Required<NonNullable<Branch['policies']>> {
20
- const privacy = branch.policies?.privacy ?? 'plain'
21
- // Non-plain privacy implies hidden by default: the CMS hashes/encrypts on write,
22
- // so returning the stored value would leak the digest to readers.
23
- const defaultVisibility = privacy !== 'plain' ? 'hidden' : 'full'
24
- return {
25
- privacy,
26
- visibility: branch.policies?.visibility ?? defaultVisibility,
27
- search: branch.policies?.search ?? true,
28
- filter: branch.policies?.filter ?? true,
29
- sort: branch.policies?.sort ?? true,
30
- public: branch.policies?.public ?? true,
31
- }
32
- }