@delma/fylo 2.0.1 → 2.1.1

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 (107) hide show
  1. package/README.md +206 -261
  2. package/dist/adapters/cipher.js +155 -0
  3. package/dist/adapters/cipher.js.map +1 -0
  4. package/dist/core/collection.js +6 -0
  5. package/dist/core/collection.js.map +1 -0
  6. package/dist/core/directory.js +48 -0
  7. package/dist/core/directory.js.map +1 -0
  8. package/dist/core/doc-id.js +15 -0
  9. package/dist/core/doc-id.js.map +1 -0
  10. package/dist/core/extensions.js +16 -0
  11. package/dist/core/extensions.js.map +1 -0
  12. package/dist/core/format.js +355 -0
  13. package/dist/core/format.js.map +1 -0
  14. package/dist/core/parser.js +764 -0
  15. package/dist/core/parser.js.map +1 -0
  16. package/dist/core/query.js +47 -0
  17. package/dist/core/query.js.map +1 -0
  18. package/dist/engines/s3-files/documents.js +62 -0
  19. package/dist/engines/s3-files/documents.js.map +1 -0
  20. package/dist/engines/s3-files/filesystem.js +165 -0
  21. package/dist/engines/s3-files/filesystem.js.map +1 -0
  22. package/dist/engines/s3-files/query.js +235 -0
  23. package/dist/engines/s3-files/query.js.map +1 -0
  24. package/dist/engines/s3-files/types.js +2 -0
  25. package/dist/engines/s3-files/types.js.map +1 -0
  26. package/dist/engines/s3-files.js +629 -0
  27. package/dist/engines/s3-files.js.map +1 -0
  28. package/dist/engines/types.js +2 -0
  29. package/dist/engines/types.js.map +1 -0
  30. package/dist/index.js +562 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/sync.js +18 -0
  33. package/dist/sync.js.map +1 -0
  34. package/dist/types/fylo.d.ts +179 -0
  35. package/{src → dist}/types/node-runtime.d.ts +1 -0
  36. package/package.json +3 -6
  37. package/.env.example +0 -16
  38. package/.github/copilot-instructions.md +0 -3
  39. package/.github/prompts/release.prompt.md +0 -10
  40. package/.github/workflows/ci.yml +0 -37
  41. package/.github/workflows/publish.yml +0 -91
  42. package/.prettierrc +0 -7
  43. package/AGENTS.md +0 -3
  44. package/CLAUDE.md +0 -3
  45. package/eslint.config.js +0 -32
  46. package/src/CLI +0 -39
  47. package/src/adapters/cipher.ts +0 -180
  48. package/src/adapters/redis.ts +0 -487
  49. package/src/adapters/s3.ts +0 -61
  50. package/src/core/collection.ts +0 -5
  51. package/src/core/directory.ts +0 -387
  52. package/src/core/extensions.ts +0 -21
  53. package/src/core/format.ts +0 -457
  54. package/src/core/parser.ts +0 -901
  55. package/src/core/query.ts +0 -53
  56. package/src/core/walker.ts +0 -174
  57. package/src/core/write-queue.ts +0 -59
  58. package/src/engines/s3-files.ts +0 -1068
  59. package/src/engines/types.ts +0 -21
  60. package/src/index.ts +0 -1727
  61. package/src/migrate-cli.ts +0 -22
  62. package/src/migrate.ts +0 -74
  63. package/src/types/fylo.d.ts +0 -261
  64. package/src/types/write-queue.ts +0 -42
  65. package/src/worker.ts +0 -18
  66. package/src/workers/write-worker.ts +0 -120
  67. package/tests/collection/truncate.test.js +0 -35
  68. package/tests/data.js +0 -97
  69. package/tests/index.js +0 -14
  70. package/tests/integration/aws-s3-files.canary.test.js +0 -22
  71. package/tests/integration/create.test.js +0 -39
  72. package/tests/integration/delete.test.js +0 -95
  73. package/tests/integration/edge-cases.test.js +0 -158
  74. package/tests/integration/encryption.test.js +0 -131
  75. package/tests/integration/export.test.js +0 -46
  76. package/tests/integration/join-modes.test.js +0 -154
  77. package/tests/integration/migration.test.js +0 -38
  78. package/tests/integration/nested.test.js +0 -142
  79. package/tests/integration/operators.test.js +0 -122
  80. package/tests/integration/queue.test.js +0 -83
  81. package/tests/integration/read.test.js +0 -119
  82. package/tests/integration/rollback.test.js +0 -60
  83. package/tests/integration/s3-files.test.js +0 -192
  84. package/tests/integration/update.test.js +0 -99
  85. package/tests/mocks/cipher.js +0 -40
  86. package/tests/mocks/redis.js +0 -123
  87. package/tests/mocks/s3.js +0 -80
  88. package/tests/schemas/album.d.ts +0 -5
  89. package/tests/schemas/album.json +0 -5
  90. package/tests/schemas/comment.d.ts +0 -7
  91. package/tests/schemas/comment.json +0 -7
  92. package/tests/schemas/photo.d.ts +0 -7
  93. package/tests/schemas/photo.json +0 -7
  94. package/tests/schemas/post.d.ts +0 -6
  95. package/tests/schemas/post.json +0 -6
  96. package/tests/schemas/tip.d.ts +0 -7
  97. package/tests/schemas/tip.json +0 -7
  98. package/tests/schemas/todo.d.ts +0 -6
  99. package/tests/schemas/todo.json +0 -6
  100. package/tests/schemas/user.d.ts +0 -23
  101. package/tests/schemas/user.json +0 -23
  102. package/tsconfig.json +0 -21
  103. package/tsconfig.typecheck.json +0 -31
  104. /package/{src → dist}/types/bun-runtime.d.ts +0 -0
  105. /package/{src → dist}/types/index.d.ts +0 -0
  106. /package/{src → dist}/types/query.d.ts +0 -0
  107. /package/{src → dist}/types/vendor-modules.d.ts +0 -0
@@ -0,0 +1,179 @@
1
+ interface _getDoc {
2
+ [Symbol.asyncIterator]<T>(): AsyncGenerator<_ttid | Record<_ttid, T>, void, unknown>
3
+ once<T>(): Promise<Record<_ttid, T>>
4
+ onDelete(): AsyncGenerator<_ttid, void, unknown>
5
+ }
6
+
7
+ interface _findDocs {
8
+ [Symbol.asyncIterator]<T>(): AsyncGenerator<
9
+ _ttid | Record<_ttid, T> | Record<string, _ttid[]> | Record<_ttid, Partial<T>> | undefined,
10
+ void,
11
+ unknown
12
+ >
13
+ collect<T>(): AsyncGenerator<
14
+ _ttid | Record<_ttid, T> | Record<string, _ttid[]> | Record<_ttid, Partial<T>> | undefined,
15
+ void,
16
+ unknown
17
+ >
18
+ onDelete(): AsyncGenerator<_ttid, void, unknown>
19
+ }
20
+
21
+ interface ObjectConstructor {
22
+ appendGroup: (target: Record<string, any>, source: Record<string, any>) => Record<string, any>
23
+ }
24
+
25
+ interface Console {
26
+ format: (docs: Record<string, any>) => void
27
+ }
28
+
29
+ type _joinDocs<T, U> =
30
+ | _ttid[]
31
+ | Record<string, _ttid[]>
32
+ | Record<string, Record<_ttid, Partial<T | U>>>
33
+ | Record<`${_ttid}, ${_ttid}`, T | U | (T & U) | (Partial<T> & Partial<U>)>
34
+
35
+ type _fyloSyncMode = 'await-sync' | 'fire-and-forget'
36
+
37
+ interface _fyloWriteSyncEvent<T extends Record<string, any> = Record<string, any>> {
38
+ operation: 'put' | 'patch'
39
+ collection: string
40
+ docId: _ttid
41
+ previousDocId?: _ttid
42
+ path: string
43
+ data: T
44
+ }
45
+
46
+ interface _fyloDeleteSyncEvent {
47
+ operation: 'delete' | 'patch'
48
+ collection: string
49
+ docId: _ttid
50
+ path: string
51
+ }
52
+
53
+ interface _fyloSyncHooks<T extends Record<string, any> = Record<string, any>> {
54
+ onWrite?: (event: _fyloWriteSyncEvent<T>) => Promise<void> | void
55
+ onDelete?: (event: _fyloDeleteSyncEvent) => Promise<void> | void
56
+ }
57
+
58
+ interface _fyloOptions {
59
+ root?: string
60
+ s3FilesRoot?: string
61
+ sync?: _fyloSyncHooks
62
+ syncMode?: _fyloSyncMode
63
+ }
64
+
65
+ interface _importBulkDataOptions {
66
+ limit?: number
67
+ maxBytes?: number
68
+ allowedProtocols?: string[]
69
+ allowedHosts?: string[]
70
+ allowPrivateNetwork?: boolean
71
+ }
72
+
73
+ declare module '@delma/fylo' {
74
+ export class FyloSyncError extends Error {
75
+ readonly collection: string
76
+ readonly docId: _ttid
77
+ readonly path: string
78
+ readonly operation: string
79
+ }
80
+
81
+ export type FyloSyncMode = _fyloSyncMode
82
+ export type FyloWriteSyncEvent<T extends Record<string, any> = Record<string, any>> =
83
+ _fyloWriteSyncEvent<T>
84
+ export type FyloDeleteSyncEvent = _fyloDeleteSyncEvent
85
+ export type FyloSyncHooks<T extends Record<string, any> = Record<string, any>> =
86
+ _fyloSyncHooks<T>
87
+ export type FyloOptions = _fyloOptions
88
+ export type ImportBulkDataOptions = _importBulkDataOptions
89
+
90
+ export default class {
91
+ constructor(options?: _fyloOptions)
92
+
93
+ /**
94
+ * Compatibility helper. FYLO now writes synchronously to the filesystem,
95
+ * so rollback is a no-op.
96
+ */
97
+ rollback(): Promise<void>
98
+
99
+ /**
100
+ * Executes a SQL query and returns the results.
101
+ * @param SQL The SQL query to execute.
102
+ * @returns The results of the query.
103
+ */
104
+ executeSQL<T extends Record<string, any>, U extends Record<string, any> = {}>(
105
+ SQL: string
106
+ ): Promise<number | void | any[] | _ttid | Record<any, any>>
107
+
108
+ static createCollection(collection: string): Promise<void>
109
+ static dropCollection(collection: string): Promise<void>
110
+
111
+ createCollection(collection: string): Promise<void>
112
+ dropCollection(collection: string): Promise<void>
113
+
114
+ importBulkData(
115
+ collection: string,
116
+ url: URL,
117
+ limitOrOptions?: number | _importBulkDataOptions
118
+ ): Promise<number>
119
+
120
+ exportBulkData<T extends Record<string, any>>(
121
+ collection: string
122
+ ): AsyncGenerator<T, void, unknown>
123
+
124
+ static exportBulkData<T extends Record<string, any>>(
125
+ collection: string
126
+ ): AsyncGenerator<T, void, unknown>
127
+
128
+ static getDoc(collection: string, _id: _ttid, onlyId?: boolean): _getDoc
129
+
130
+ getDoc(collection: string, _id: _ttid, onlyId?: boolean): _getDoc
131
+
132
+ batchPutData<T extends Record<string, any>>(
133
+ collection: string,
134
+ batch: Array<T>
135
+ ): Promise<_ttid[]>
136
+
137
+ putData<T extends Record<string, any>>(collection: string, data: T): Promise<_ttid>
138
+ putData<T extends Record<string, any>>(
139
+ collection: string,
140
+ data: Record<_ttid, T>
141
+ ): Promise<_ttid>
142
+
143
+ patchDoc<T extends Record<string, any>>(
144
+ collection: string,
145
+ newDoc: Record<_ttid, Partial<T>>,
146
+ oldDoc?: Record<_ttid, T>
147
+ ): Promise<_ttid>
148
+
149
+ patchDocs<T extends Record<string, any>>(
150
+ collection: string,
151
+ updateSchema: _storeUpdate<T>
152
+ ): Promise<number>
153
+
154
+ delDoc(collection: string, _id: _ttid): Promise<void>
155
+
156
+ delDocs<T extends Record<string, any>>(
157
+ collection: string,
158
+ deleteSchema?: _storeDelete<T>
159
+ ): Promise<number>
160
+
161
+ static joinDocs<T extends Record<string, any>, U extends Record<string, any>>(
162
+ join: _join<T, U>
163
+ ): Promise<_joinDocs<T, U>>
164
+
165
+ joinDocs<T extends Record<string, any>, U extends Record<string, any>>(
166
+ join: _join<T, U>
167
+ ): Promise<_joinDocs<T, U>>
168
+
169
+ static findDocs<T extends Record<string, any>>(
170
+ collection: string,
171
+ query?: _storeQuery<T>
172
+ ): _findDocs
173
+
174
+ findDocs<T extends Record<string, any>>(
175
+ collection: string,
176
+ query?: _storeQuery<T>
177
+ ): _findDocs
178
+ }
179
+ }
@@ -1,5 +1,6 @@
1
1
  declare const process: {
2
2
  env: Record<string, string | undefined>
3
+ cwd(): string
3
4
  }
4
5
 
5
6
  declare const Buffer: {
package/package.json CHANGED
@@ -1,18 +1,15 @@
1
1
  {
2
2
  "name": "@delma/fylo",
3
- "version": "2.0.1",
3
+ "version": "2.1.1",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/types/index.d.ts",
6
6
  "bin": {
7
- "fylo.query": "./dist/cli/index.js",
8
- "fylo.worker": "./dist/worker.js",
9
- "fylo.migrate": "./dist/migrate-cli.js"
7
+ "fylo.query": "./dist/cli/index.js"
10
8
  },
11
9
  "scripts": {
12
- "build": "tsc",
10
+ "build": "rm -rf dist && tsc && mkdir -p dist/types && cp src/types/*.d.ts dist/types/",
13
11
  "test": "bun test",
14
12
  "typecheck": "tsc -p tsconfig.typecheck.json",
15
- "worker": "bun run ./src/worker.ts",
16
13
  "lint": "prettier --check \"src/**/*.{ts,d.ts}\" \"tests/**/*.js\" README.md",
17
14
  "format": "prettier --write src tests"
18
15
  },
package/.env.example DELETED
@@ -1,16 +0,0 @@
1
- LOGGING=
2
- STRICT=
3
- S3_REGION=ca-central-1
4
- S3_ACCESS_KEY_ID=HELLO
5
- S3_SECRET_ACCESS_KEY=WORLD
6
- S3_ENDPOINT=https//example.com
7
- BUCKET_PREFIX="byos-test"
8
- SCHEMA_DIR=/path/to/schema/dir
9
- REDIS_URL=redis://localhost:6379
10
- REDIS_CONN_TIMEOUT=5000
11
- REDIS_IDLE_TIMEOUT=30000
12
- REDIS_AUTO_RECONNECT=
13
- REDIS_MAX_RETRIES=10
14
- REDIS_ENABLE_OFFLINE_QUEUE=
15
- REDIS_ENABLE_AUTO_PIPELINING=
16
- REDIS_TLS=
@@ -1,3 +0,0 @@
1
- Follow the shared workspace instructions in `../../INSTRUCTIONS.md`, shared context in `../../MEMORY.md`, and shared release process in `../../RELEASE.md`.
2
-
3
- If a repo-local instruction file adds stricter rules, follow the repo-local rule.
@@ -1,10 +0,0 @@
1
- ---
2
- description: "Follow the shared workspace release process"
3
- agent: "agent"
4
- tools: [runInTerminal]
5
- ---
6
- Follow the shared workspace release process in [../../../RELEASE.md](../../../RELEASE.md).
7
-
8
- Use the repo's actual default branch. Do not assume it is `main`.
9
-
10
- If repo-local workflows impose stricter checks or branch requirements, follow the repo-local workflow and then update the shared release guide later if that rule becomes the new standard.
@@ -1,37 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [release/*]
6
- pull_request:
7
- branches: [main]
8
-
9
- jobs:
10
- test:
11
- name: Test (Bun ${{ matrix.bun-version }})
12
- runs-on: ubuntu-latest
13
-
14
- strategy:
15
- matrix:
16
- bun-version: [latest, 1.2.x]
17
-
18
- steps:
19
- - name: Checkout
20
- uses: actions/checkout@v4
21
-
22
- - name: Setup Bun
23
- uses: oven-sh/setup-bun@v2
24
- with:
25
- bun-version: ${{ matrix.bun-version }}
26
-
27
- - name: Install dependencies
28
- run: bun install --frozen-lockfile
29
-
30
- - name: Type check
31
- run: bun run typecheck
32
-
33
- - name: Lint
34
- run: bun run lint
35
-
36
- - name: Run tests
37
- run: bun test
@@ -1,91 +0,0 @@
1
- name: Publish
2
-
3
- on:
4
- push:
5
- branches: [release/*]
6
-
7
- jobs:
8
- test:
9
- name: Test before publish
10
- runs-on: ubuntu-latest
11
-
12
- steps:
13
- - name: Checkout
14
- uses: actions/checkout@v4
15
-
16
- - name: Setup Bun
17
- uses: oven-sh/setup-bun@v2
18
- with:
19
- bun-version: latest
20
-
21
- - name: Install dependencies
22
- run: bun install --frozen-lockfile
23
-
24
- - name: Type check
25
- run: bun run typecheck
26
-
27
- - name: Lint
28
- run: bun run lint
29
-
30
- - name: Run tests
31
- run: bun test
32
-
33
- publish:
34
- name: Publish to npm
35
- runs-on: ubuntu-latest
36
- needs: test
37
- permissions:
38
- contents: write # create git tags
39
- id-token: write # npm provenance
40
-
41
- steps:
42
- - name: Checkout
43
- uses: actions/checkout@v4
44
-
45
- - name: Setup Bun
46
- uses: oven-sh/setup-bun@v2
47
- with:
48
- bun-version: latest
49
-
50
- - name: Install dependencies
51
- run: bun install --frozen-lockfile
52
-
53
- - name: Setup Node and upgrade npm
54
- uses: actions/setup-node@v4
55
- with:
56
- node-version: '20'
57
-
58
- - name: Upgrade npm
59
- run: npm install -g npm@latest
60
-
61
- - name: Resolve version from branch
62
- id: version
63
- run: |
64
- BRANCH="${GITHUB_REF#refs/heads/}"
65
- VERSION="${BRANCH#release/}"
66
- PKG_VERSION=$(bun -e "console.log(require('./package.json').version)")
67
- if [ "$VERSION" != "$PKG_VERSION" ]; then
68
- echo "Branch version ($VERSION) does not match package.json ($PKG_VERSION). Skipping publish."
69
- exit 1
70
- fi
71
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
72
-
73
- - name: Publish to npm (Trusted Publishing via OIDC)
74
- run: |
75
- echo "registry=https://registry.npmjs.org/" >> ~/.npmrc
76
- npm publish --access public --provenance
77
-
78
- - name: Create and push version tag
79
- run: |
80
- git config user.name "github-actions[bot]"
81
- git config user.email "github-actions[bot]@users.noreply.github.com"
82
- git tag -f -a "v${{ steps.version.outputs.version }}" -m "v${{ steps.version.outputs.version }}"
83
- git push origin "v${{ steps.version.outputs.version }}" --force
84
-
85
- - name: Create GitHub release
86
- run: |
87
- gh release create "v${{ steps.version.outputs.version }}" \
88
- --title "v${{ steps.version.outputs.version }}" \
89
- --generate-notes
90
- env:
91
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
package/.prettierrc DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "semi": false,
3
- "singleQuote": true,
4
- "tabWidth": 4,
5
- "trailingComma": "none",
6
- "printWidth": 100
7
- }
package/AGENTS.md DELETED
@@ -1,3 +0,0 @@
1
- Follow the shared workspace instructions in [../INSTRUCTIONS.md](../INSTRUCTIONS.md), shared context in [../MEMORY.md](../MEMORY.md), and shared release process in [../RELEASE.md](../RELEASE.md).
2
-
3
- Repo-local rules may add to or override the shared files when explicitly stated.
package/CLAUDE.md DELETED
@@ -1,3 +0,0 @@
1
- Follow the shared workspace instructions in [../INSTRUCTIONS.md](../INSTRUCTIONS.md), shared context in [../MEMORY.md](../MEMORY.md), and shared release process in [../RELEASE.md](../RELEASE.md).
2
-
3
- Repo-local rules may add to or override the shared files when explicitly stated.
package/eslint.config.js DELETED
@@ -1,32 +0,0 @@
1
- import tsPlugin from '@typescript-eslint/eslint-plugin'
2
- import tsParser from '@typescript-eslint/parser'
3
- import prettierConfig from 'eslint-config-prettier'
4
-
5
- export default [
6
- {
7
- files: ['src/**/*.ts'],
8
- languageOptions: {
9
- parser: tsParser,
10
- parserOptions: {}
11
- },
12
- plugins: {
13
- '@typescript-eslint': tsPlugin
14
- },
15
- rules: {
16
- ...tsPlugin.configs['recommended'].rules,
17
- '@typescript-eslint/no-explicit-any': 'warn',
18
- '@typescript-eslint/explicit-function-return-type': 'warn',
19
- '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }]
20
- }
21
- },
22
- {
23
- files: ['tests/**/*.js'],
24
- rules: {
25
- 'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }]
26
- }
27
- },
28
- prettierConfig,
29
- {
30
- ignores: ['bin/**', 'node_modules/**', '**/*.d.ts']
31
- }
32
- ]
package/src/CLI DELETED
@@ -1,39 +0,0 @@
1
- #!/usr/bin/env bun
2
- /// <reference path="./types/index.d.ts" />
3
- import Silo from '.'
4
-
5
- const SQL = process.argv[process.argv.length - 1]
6
-
7
- const op = SQL.match(
8
- /^((?:SELECT|select)|(?:INSERT|insert)|(?:UPDATE|update)|(?:DELETE|delete)|(?:CREATE|create)|(?:DROP|drop))/i
9
- )
10
-
11
- if (!op) throw new Error('Missing SQL Operation')
12
-
13
- const res = await new Silo().executeSQL(SQL)
14
-
15
- const cmnd = op.shift()!
16
-
17
- switch (cmnd.toUpperCase()) {
18
- case 'CREATE':
19
- console.log('Successfully created schema')
20
- break
21
- case 'DROP':
22
- console.log('Successfully dropped schema')
23
- break
24
- case 'SELECT':
25
- if (typeof res === 'object' && !Array.isArray(res)) console.format(res)
26
- else console.log(res)
27
- break
28
- case 'INSERT':
29
- console.log(res)
30
- break
31
- case 'UPDATE':
32
- console.log(`Successfully updated ${res} document(s)`)
33
- break
34
- case 'DELETE':
35
- console.log(`Successfully deleted ${res} document(s)`)
36
- break
37
- default:
38
- throw new Error('Invalid Operation: ' + cmnd)
39
- }
@@ -1,180 +0,0 @@
1
- /**
2
- * AES-256-CBC encryption adapter for field-level value encryption.
3
- *
4
- * Two modes are supported via the `deterministic` flag on `encrypt()`:
5
- *
6
- * - **Random IV (default)**: A cryptographically random IV is generated per
7
- * encryption operation. Identical plaintexts produce different ciphertexts.
8
- * Use this for fields that do not need exact-match ($eq/$ne) queries.
9
- *
10
- * - **Deterministic IV (opt-in)**: The IV is derived from HMAC-SHA256 of the
11
- * plaintext, so identical values always produce identical ciphertext. This
12
- * enables exact-match queries on encrypted fields but leaks equality — an
13
- * observer can determine which records share field values without decrypting.
14
- * Use only when $eq/$ne queries on encrypted fields are required.
15
- *
16
- * Encrypted fields are declared per-collection in JSON schema files via the
17
- * `$encrypted` array. The encryption key is sourced from `ENCRYPTION_KEY` env var.
18
- * Set `CIPHER_SALT` to a unique random value to prevent cross-deployment attacks.
19
- */
20
-
21
- export class Cipher {
22
- private static key: CryptoKey | null = null
23
- private static hmacKey: CryptoKey | null = null
24
-
25
- /** Per-collection encrypted field sets, loaded from schema `$encrypted` arrays. */
26
- private static collections: Map<string, Set<string>> = new Map()
27
-
28
- static isConfigured(): boolean {
29
- return Cipher.key !== null
30
- }
31
-
32
- static hasEncryptedFields(collection: string): boolean {
33
- const fields = Cipher.collections.get(collection)
34
- return !!fields && fields.size > 0
35
- }
36
-
37
- static isEncryptedField(collection: string, field: string): boolean {
38
- const fields = Cipher.collections.get(collection)
39
- if (!fields || fields.size === 0) return false
40
-
41
- for (const pattern of fields) {
42
- if (field === pattern) return true
43
- // Support nested: encrypting "address" encrypts "address/city" etc.
44
- if (field.startsWith(`${pattern}/`)) return true
45
- }
46
-
47
- return false
48
- }
49
-
50
- /**
51
- * Registers encrypted fields for a collection (from schema `$encrypted` array).
52
- */
53
- static registerFields(collection: string, fields: string[]): void {
54
- if (fields.length > 0) {
55
- Cipher.collections.set(collection, new Set(fields))
56
- }
57
- }
58
-
59
- /**
60
- * Derives AES + HMAC keys from a secret string. Called once at startup.
61
- */
62
- static async configure(secret: string): Promise<void> {
63
- const encoder = new TextEncoder()
64
- const keyMaterial = await crypto.subtle.importKey(
65
- 'raw',
66
- encoder.encode(secret),
67
- 'PBKDF2',
68
- false,
69
- ['deriveBits']
70
- )
71
-
72
- const cipherSalt = process.env.CIPHER_SALT
73
- if (!cipherSalt) {
74
- console.warn(
75
- 'CIPHER_SALT is not set. Using default salt is insecure for multi-deployment use. Set CIPHER_SALT to a unique random value.'
76
- )
77
- }
78
-
79
- // Derive 48 bytes: 32 for AES key + 16 for HMAC key
80
- const bits = await crypto.subtle.deriveBits(
81
- {
82
- name: 'PBKDF2',
83
- salt: encoder.encode(cipherSalt ?? 'fylo-cipher'),
84
- iterations: 100000,
85
- hash: 'SHA-256'
86
- },
87
- keyMaterial,
88
- 384
89
- )
90
-
91
- const derived = new Uint8Array(bits)
92
-
93
- Cipher.key = await crypto.subtle.importKey(
94
- 'raw',
95
- derived.slice(0, 32),
96
- { name: 'AES-CBC' },
97
- false,
98
- ['encrypt', 'decrypt']
99
- )
100
-
101
- Cipher.hmacKey = await crypto.subtle.importKey(
102
- 'raw',
103
- derived.slice(32),
104
- { name: 'HMAC', hash: 'SHA-256' },
105
- false,
106
- ['sign']
107
- )
108
- }
109
-
110
- static reset(): void {
111
- Cipher.key = null
112
- Cipher.hmacKey = null
113
- Cipher.collections = new Map()
114
- }
115
-
116
- /**
117
- * Deterministic IV from HMAC-SHA256 of plaintext, truncated to 16 bytes.
118
- */
119
- private static async deriveIV(plaintext: string): Promise<Uint8Array> {
120
- const encoder = new TextEncoder()
121
- const sig = await crypto.subtle.sign('HMAC', Cipher.hmacKey!, encoder.encode(plaintext))
122
- return new Uint8Array(sig).slice(0, 16)
123
- }
124
-
125
- /**
126
- * Encrypts a value. Returns a URL-safe base64 string (no slashes).
127
- *
128
- * @param value - The plaintext to encrypt.
129
- * @param deterministic - When true, derives IV from HMAC of plaintext (same
130
- * input always produces same ciphertext). Required for $eq/$ne queries on
131
- * encrypted fields. Defaults to false (random IV per operation).
132
- */
133
- static async encrypt(value: string, deterministic = false): Promise<string> {
134
- if (!Cipher.key) throw new Error('Cipher not configured — set ENCRYPTION_KEY env var')
135
-
136
- const iv = deterministic
137
- ? await Cipher.deriveIV(value)
138
- : crypto.getRandomValues(new Uint8Array(16))
139
- const encoder = new TextEncoder()
140
-
141
- const encrypted = await crypto.subtle.encrypt(
142
- { name: 'AES-CBC', iv: iv as any },
143
- Cipher.key,
144
- encoder.encode(value)
145
- )
146
-
147
- // Concatenate IV + ciphertext and encode as URL-safe base64
148
- const combined = new Uint8Array(iv.length + encrypted.byteLength)
149
- combined.set(iv)
150
- combined.set(new Uint8Array(encrypted), iv.length)
151
-
152
- return btoa(String.fromCharCode(...combined))
153
- .replace(/\+/g, '-')
154
- .replace(/\//g, '_')
155
- .replace(/=+$/, '')
156
- }
157
-
158
- /**
159
- * Decrypts a URL-safe base64 encoded value back to plaintext.
160
- */
161
- static async decrypt(encoded: string): Promise<string> {
162
- if (!Cipher.key) throw new Error('Cipher not configured — set ENCRYPTION_KEY env var')
163
-
164
- // Restore standard base64
165
- const b64 = encoded.replace(/-/g, '+').replace(/_/g, '/')
166
- const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)
167
-
168
- const combined = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0))
169
- const iv = combined.slice(0, 16)
170
- const ciphertext = combined.slice(16)
171
-
172
- const decrypted = await crypto.subtle.decrypt(
173
- { name: 'AES-CBC', iv },
174
- Cipher.key,
175
- ciphertext
176
- )
177
-
178
- return new TextDecoder().decode(decrypted)
179
- }
180
- }