@chipmobilesdk/rn-local-db 0.1.0

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 (81) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +726 -0
  3. package/package.json +61 -0
  4. package/src/adapter/budgets.ts +67 -0
  5. package/src/adapter/collection.ts +298 -0
  6. package/src/adapter/deletion.ts +238 -0
  7. package/src/adapter/index.ts +68 -0
  8. package/src/adapter/lifecycle.ts +444 -0
  9. package/src/adapter/transaction.ts +65 -0
  10. package/src/artifacts/availability.ts +69 -0
  11. package/src/artifacts/cascade.ts +78 -0
  12. package/src/artifacts/paths.ts +29 -0
  13. package/src/artifacts/reconcile.ts +63 -0
  14. package/src/artifacts/store.ts +323 -0
  15. package/src/contract/adapter.ts +78 -0
  16. package/src/contract/attachment.ts +72 -0
  17. package/src/contract/backup.ts +28 -0
  18. package/src/contract/config.ts +54 -0
  19. package/src/contract/encryption.ts +34 -0
  20. package/src/contract/errors.ts +76 -0
  21. package/src/contract/migration.ts +40 -0
  22. package/src/contract/query.ts +81 -0
  23. package/src/contract/record.ts +42 -0
  24. package/src/contract/schema.ts +36 -0
  25. package/src/contract/scope.ts +36 -0
  26. package/src/contract/transaction.ts +46 -0
  27. package/src/crypto/chunked-cipher.ts +144 -0
  28. package/src/crypto/derive.ts +63 -0
  29. package/src/crypto/key-provider.ts +78 -0
  30. package/src/crypto/provider.ts +81 -0
  31. package/src/diagnostics/dev-log.ts +108 -0
  32. package/src/diagnostics/errors.ts +66 -0
  33. package/src/diagnostics/faults.ts +64 -0
  34. package/src/diagnostics/map-native.ts +106 -0
  35. package/src/diagnostics/redact.ts +141 -0
  36. package/src/index.ts +110 -0
  37. package/src/migration/executor.ts +136 -0
  38. package/src/migration/ledger.ts +97 -0
  39. package/src/migration/validate-chain.ts +65 -0
  40. package/src/platform/backup-android-verify.ts +88 -0
  41. package/src/platform/backup-ios.ts +48 -0
  42. package/src/platform/fs.ts +89 -0
  43. package/src/platform/location.ts +135 -0
  44. package/src/query/budgets.ts +57 -0
  45. package/src/query/compare.ts +91 -0
  46. package/src/query/cursor.ts +99 -0
  47. package/src/query/filter.ts +82 -0
  48. package/src/query/paginate.ts +51 -0
  49. package/src/query/sort.ts +56 -0
  50. package/src/query/validate.ts +171 -0
  51. package/src/schema/declare.ts +45 -0
  52. package/src/schema/validate-config.ts +158 -0
  53. package/src/scope/hash.ts +134 -0
  54. package/src/scope/normalize.ts +90 -0
  55. package/src/scope/validate.ts +83 -0
  56. package/src/sqlite/adapter.ts +26 -0
  57. package/src/sqlite/batch.ts +232 -0
  58. package/src/sqlite/catalog.ts +108 -0
  59. package/src/sqlite/connection.ts +205 -0
  60. package/src/sqlite/index-coverage.ts +53 -0
  61. package/src/sqlite/native.ts +83 -0
  62. package/src/sqlite/row-codec.ts +201 -0
  63. package/src/sqlite/sql-builder.ts +244 -0
  64. package/src/sqlite/transactions.ts +33 -0
  65. package/src/sqlite/values.ts +24 -0
  66. package/src/testing/conformance/attachments.ts +136 -0
  67. package/src/testing/conformance/encryption.ts +150 -0
  68. package/src/testing/conformance/index.ts +161 -0
  69. package/src/testing/conformance/lifecycle.ts +99 -0
  70. package/src/testing/conformance/migrations.ts +177 -0
  71. package/src/testing/conformance/queries.ts +167 -0
  72. package/src/testing/conformance/records.ts +169 -0
  73. package/src/testing/conformance/scopes.ts +88 -0
  74. package/src/testing/conformance/transactions.ts +149 -0
  75. package/src/testing/faults.ts +21 -0
  76. package/src/testing/index.ts +21 -0
  77. package/src/testing/memory-adapter/collection.ts +219 -0
  78. package/src/testing/memory-adapter/index.ts +388 -0
  79. package/src/testing/memory-adapter/migrations.ts +119 -0
  80. package/src/testing/memory-adapter/reset.ts +50 -0
  81. package/src/testing/memory-adapter/store.ts +134 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Query validation, run before any SQL is built.
3
+ *
4
+ * Two things fall out of validating first: FR-034 is satisfied, and no
5
+ * consumer-supplied string ever reaches SQL construction — field names are
6
+ * resolved through the schema to a known column, and values bind as
7
+ * parameters. Injection stops being a thing to remember.
8
+ */
9
+
10
+ import { Budgets } from '../contract/config';
11
+ import { DiagnosticCodes } from '../contract/errors';
12
+ import type { FilterNode, FilterValue, Query, SortKey } from '../contract/query';
13
+ import type { CollectionSchema, FieldType } from '../contract/schema';
14
+ import { raise } from '../diagnostics/errors';
15
+ import { sanitizeIdentifier } from '../diagnostics/redact';
16
+ import { fieldOf } from '../schema/declare';
17
+
18
+ function queryInvalid(message: string, collection: string): never {
19
+ // The field name is named; its *value* never is (FR-079).
20
+ raise(DiagnosticCodes.QUERY_INVALID, { message, detail: { collection } });
21
+ }
22
+
23
+ const ORDERED_OPERATORS = new Set(['lt', 'lte', 'gt', 'gte', 'between']);
24
+
25
+ function typeMatches(type: FieldType, value: FilterValue): boolean {
26
+ if (value === null) {
27
+ return true;
28
+ }
29
+ switch (type) {
30
+ case 'string':
31
+ return typeof value === 'string';
32
+ case 'number':
33
+ case 'timestamp':
34
+ return typeof value === 'number';
35
+ case 'boolean':
36
+ return typeof value === 'boolean';
37
+ default:
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function validateField(
43
+ collection: CollectionSchema,
44
+ field: string,
45
+ ): ReturnType<typeof fieldOf> {
46
+ const declared = fieldOf(collection, field);
47
+ if (!declared) {
48
+ // Returning everything or nothing here would be worse than failing:
49
+ // a typo would silently change what the user sees.
50
+ queryInvalid(`filter references undeclared field ${sanitizeIdentifier(field)}`, collection.name);
51
+ }
52
+ return declared;
53
+ }
54
+
55
+ export function validateFilter(collection: CollectionSchema, node: FilterNode): void {
56
+ switch (node.op) {
57
+ case 'and':
58
+ case 'or': {
59
+ if (!Array.isArray(node.nodes) || node.nodes.length === 0) {
60
+ queryInvalid(`${node.op} requires at least one child`, collection.name);
61
+ }
62
+ for (const child of node.nodes) {
63
+ validateFilter(collection, child);
64
+ }
65
+ return;
66
+ }
67
+ case 'not': {
68
+ if (!node.node) {
69
+ queryInvalid('not requires a child', collection.name);
70
+ }
71
+ validateFilter(collection, node.node);
72
+ return;
73
+ }
74
+ case 'isNull':
75
+ case 'isNotNull': {
76
+ validateField(collection, node.field);
77
+ return;
78
+ }
79
+ case 'exists': {
80
+ validateField(collection, node.field);
81
+ if (typeof node.value !== 'boolean') {
82
+ queryInvalid('exists requires a boolean', collection.name);
83
+ }
84
+ return;
85
+ }
86
+ case 'in':
87
+ case 'nin': {
88
+ const declared = validateField(collection, node.field)!;
89
+ if (!Array.isArray(node.value) || node.value.length === 0) {
90
+ queryInvalid(`${node.op} requires a non-empty array`, collection.name);
91
+ }
92
+ for (const value of node.value) {
93
+ if (!typeMatches(declared.type, value)) {
94
+ queryInvalid(`${node.op} value does not match field type`, collection.name);
95
+ }
96
+ }
97
+ return;
98
+ }
99
+ case 'between': {
100
+ const declared = validateField(collection, node.field)!;
101
+ if (!Array.isArray(node.value) || node.value.length !== 2) {
102
+ queryInvalid('between requires exactly two bounds', collection.name);
103
+ }
104
+ for (const value of node.value) {
105
+ if (!typeMatches(declared.type, value)) {
106
+ queryInvalid('between bound does not match field type', collection.name);
107
+ }
108
+ }
109
+ return;
110
+ }
111
+ case 'eq':
112
+ case 'ne':
113
+ case 'lt':
114
+ case 'lte':
115
+ case 'gt':
116
+ case 'gte': {
117
+ const declared = validateField(collection, node.field)!;
118
+ if (declared.type === 'boolean' && ORDERED_OPERATORS.has(node.op)) {
119
+ queryInvalid('ordered comparison is not defined for a boolean', collection.name);
120
+ }
121
+ if (!typeMatches(declared.type, node.value)) {
122
+ queryInvalid('filter value does not match field type', collection.name);
123
+ }
124
+ return;
125
+ }
126
+ default: {
127
+ queryInvalid(
128
+ `unsupported operator ${sanitizeIdentifier(String((node as { op: string }).op))}`,
129
+ collection.name,
130
+ );
131
+ }
132
+ }
133
+ }
134
+
135
+ export function validateSort(collection: CollectionSchema, sort: SortKey[]): void {
136
+ const seen = new Set<string>();
137
+ for (const key of sort) {
138
+ validateField(collection, key.field);
139
+ if (seen.has(key.field)) {
140
+ queryInvalid(`duplicate sort field ${sanitizeIdentifier(key.field)}`, collection.name);
141
+ }
142
+ seen.add(key.field);
143
+ if (key.direction && key.direction !== 'asc' && key.direction !== 'desc') {
144
+ queryInvalid('sort direction must be asc or desc', collection.name);
145
+ }
146
+ }
147
+ }
148
+
149
+ export function validatePageSize(size: number): void {
150
+ if (!Number.isInteger(size) || size < 1 || size > Budgets.MAX_PAGE_SIZE) {
151
+ raise(DiagnosticCodes.PAGE_SIZE_INVALID, {
152
+ message: 'page size out of range',
153
+ detail: { limit: Budgets.MAX_PAGE_SIZE },
154
+ });
155
+ }
156
+ }
157
+
158
+ export function validateQuery(collection: CollectionSchema, query: Query | undefined): void {
159
+ if (!query) {
160
+ return;
161
+ }
162
+ if (query.filter) {
163
+ validateFilter(collection, query.filter);
164
+ }
165
+ if (query.sort) {
166
+ validateSort(collection, query.sort);
167
+ }
168
+ if (query.page) {
169
+ validatePageSize(query.page.size);
170
+ }
171
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Schema lookup helpers shared by both adapters.
3
+ *
4
+ * Declaring a field makes it queryable — it does not restrict what a record
5
+ * may contain. `isQueryable` is the single answer to "may a filter or sort
6
+ * reference this?", so both adapters reject the same field names (FR-034).
7
+ */
8
+
9
+ import type { CollectionSchema, FieldDeclaration } from '../contract/schema';
10
+
11
+ export type SchemaIndex = ReadonlyMap<string, CollectionSchema>;
12
+
13
+ export function indexSchemas(collections: CollectionSchema[]): SchemaIndex {
14
+ return new Map(collections.map(collection => [collection.name, collection]));
15
+ }
16
+
17
+ export function fieldOf(
18
+ collection: CollectionSchema,
19
+ name: string,
20
+ ): FieldDeclaration | undefined {
21
+ return collection.fields?.find(field => field.name === name);
22
+ }
23
+
24
+ export function isQueryable(collection: CollectionSchema, name: string): boolean {
25
+ return fieldOf(collection, name) !== undefined;
26
+ }
27
+
28
+ export function hasSoftDelete(collection: CollectionSchema): boolean {
29
+ return collection.softDelete === true;
30
+ }
31
+
32
+ export function hasTimestamps(collection: CollectionSchema): boolean {
33
+ return collection.timestamps === true;
34
+ }
35
+
36
+ /** Column name for a projected queryable field. Prefixed to avoid colliding
37
+ * with the reserved columns (`id`, `payload`, `created_at`, ...). */
38
+ export function projectedColumn(field: string): string {
39
+ return `f_${field}`;
40
+ }
41
+
42
+ /** Table name for a collection, prefixed for the same reason. */
43
+ export function collectionTable(name: string): string {
44
+ return `c_${name}`;
45
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Configuration validation.
3
+ *
4
+ * Runs to completion before any write and before any migration executes
5
+ * (FR-006, FR-053). Order matters: scope first, then collections, then the
6
+ * migration chain — so a consumer fixes the outermost problem first rather
7
+ * than chasing a downstream symptom.
8
+ */
9
+
10
+ import { Budgets, type DatabaseConfig } from '../contract/config';
11
+ import { DiagnosticCodes } from '../contract/errors';
12
+ import type { CollectionSchema } from '../contract/schema';
13
+ import { raise } from '../diagnostics/errors';
14
+ import { validateDescriptor } from '../scope/validate';
15
+
16
+ const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_]{0,62}$/;
17
+
18
+ function configInvalid(message: string, detail?: Record<string, unknown>): never {
19
+ raise(DiagnosticCodes.CONFIG_INVALID, { message, detail });
20
+ }
21
+
22
+ function validateCollection(collection: CollectionSchema, seen: Set<string>): void {
23
+ if (!IDENTIFIER.test(collection.name)) {
24
+ configInvalid('collection name is not a valid identifier', {
25
+ collection: collection.name,
26
+ });
27
+ }
28
+ if (seen.has(collection.name)) {
29
+ configInvalid('duplicate collection name', { collection: collection.name });
30
+ }
31
+ seen.add(collection.name);
32
+
33
+ const fieldNames = new Set<string>();
34
+ for (const field of collection.fields ?? []) {
35
+ if (!IDENTIFIER.test(field.name)) {
36
+ configInvalid('field name is not a valid identifier', {
37
+ collection: collection.name,
38
+ });
39
+ }
40
+ if (fieldNames.has(field.name)) {
41
+ configInvalid('duplicate field name', { collection: collection.name });
42
+ }
43
+ fieldNames.add(field.name);
44
+ }
45
+
46
+ const indexNames = new Set<string>();
47
+ for (const index of collection.indexes ?? []) {
48
+ if (!IDENTIFIER.test(index.name)) {
49
+ configInvalid('index name is not a valid identifier', {
50
+ collection: collection.name,
51
+ });
52
+ }
53
+ if (indexNames.has(index.name)) {
54
+ configInvalid('duplicate index name', { collection: collection.name });
55
+ }
56
+ indexNames.add(index.name);
57
+ if (!Array.isArray(index.fields) || index.fields.length === 0) {
58
+ configInvalid('index declares no fields', { collection: collection.name });
59
+ }
60
+ for (const field of index.fields) {
61
+ if (!fieldNames.has(field)) {
62
+ // The whole point of declaring fields is that the package can
63
+ // tell a typo from a deliberate choice. Silently creating an
64
+ // index over a column that will never exist would turn a config
65
+ // bug into a mysterious full scan at runtime.
66
+ configInvalid('index references an undeclared field', {
67
+ collection: collection.name,
68
+ });
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ function validateMigrations(config: DatabaseConfig): void {
75
+ const migrations = config.migrations ?? [];
76
+ if (!Number.isInteger(config.schemaVersion) || config.schemaVersion < 1) {
77
+ raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
78
+ message: 'schemaVersion must be a positive integer',
79
+ });
80
+ }
81
+
82
+ const versions = new Set<number>();
83
+ for (const migration of migrations) {
84
+ if (!Number.isInteger(migration.version) || migration.version < 1) {
85
+ raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
86
+ message: 'migration version must be a positive integer',
87
+ });
88
+ }
89
+ if (versions.has(migration.version)) {
90
+ raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
91
+ message: 'duplicate migration version',
92
+ detail: { version: migration.version },
93
+ });
94
+ }
95
+ versions.add(migration.version);
96
+ if (typeof migration.migrate !== 'function') {
97
+ raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
98
+ message: 'migration has no migrate function',
99
+ detail: { version: migration.version },
100
+ });
101
+ }
102
+ }
103
+
104
+ // A chain must be able to carry a database from 1 to schemaVersion. A gap
105
+ // means some upgrading user has no path across it, which is worse than
106
+ // refusing to open.
107
+ for (let version = 1; version <= config.schemaVersion; version += 1) {
108
+ if (!versions.has(version)) {
109
+ raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
110
+ message: 'migration chain has a gap',
111
+ detail: { version },
112
+ });
113
+ }
114
+ }
115
+
116
+ for (const version of versions) {
117
+ if (version > config.schemaVersion) {
118
+ raise(DiagnosticCodes.MIGRATION_CONFIG_INVALID, {
119
+ message: 'migration version exceeds the declared schemaVersion',
120
+ detail: { version },
121
+ });
122
+ }
123
+ }
124
+ }
125
+
126
+ export function validateConfig(config: DatabaseConfig): void {
127
+ if (!config || typeof config !== 'object') {
128
+ configInvalid('database config is required');
129
+ }
130
+ if (typeof config.name !== 'string' || !IDENTIFIER.test(config.name)) {
131
+ configInvalid('database name is not a valid identifier');
132
+ }
133
+
134
+ validateDescriptor(config.scope);
135
+
136
+ if (!Array.isArray(config.collections) || config.collections.length === 0) {
137
+ configInvalid('at least one collection must be declared');
138
+ }
139
+ if (config.collections.length > Budgets.MAX_COLLECTIONS_PER_DATABASE) {
140
+ configInvalid('collection count exceeds the budget', {
141
+ count: config.collections.length,
142
+ limit: Budgets.MAX_COLLECTIONS_PER_DATABASE,
143
+ });
144
+ }
145
+
146
+ const seen = new Set<string>();
147
+ for (const collection of config.collections) {
148
+ validateCollection(collection, seen);
149
+ }
150
+
151
+ validateMigrations(config);
152
+
153
+ if (config.encryption && config.encryption.enabled) {
154
+ if (typeof config.encryption.keyProvider?.getKey !== 'function') {
155
+ configInvalid('encryption is enabled but no key provider was supplied');
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Scope key derivation.
3
+ *
4
+ * The directory a scope lives in is named by the SHA-256 digest of its
5
+ * canonical descriptor, never by the components themselves. FR-014 requires
6
+ * scope components be treated as sensitive, and a directory named after an
7
+ * email address puts that address in plaintext on disk — visible even when
8
+ * the database itself is encrypted, and carried into any crash report that
9
+ * includes a file path.
10
+ *
11
+ * Hashing also removes filesystem charset and length limits from the problem,
12
+ * which is what lets validation reject rather than sanitize (research.md R2).
13
+ *
14
+ * Implemented in pure TypeScript rather than over a native crypto peer so the
15
+ * scope layer stays importable from anywhere in the package, including the
16
+ * in-memory adapter running under Jest.
17
+ *
18
+ * Verified against Node's own SHA-256 in scope-resolution.test.ts — a wrong
19
+ * digest would silently merge or split scopes, so it is checked against a
20
+ * reference implementation rather than against a snapshot of this one.
21
+ */
22
+
23
+ /* eslint-disable no-bitwise -- SHA-256 is defined in terms of bitwise operations. */
24
+
25
+ const K = new Uint32Array([
26
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
27
+ 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
28
+ 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
29
+ 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
30
+ 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
31
+ 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
32
+ 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
33
+ 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
34
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
35
+ 0xc67178f2,
36
+ ]);
37
+
38
+ function rotr(value: number, bits: number): number {
39
+ return (value >>> bits) | (value << (32 - bits));
40
+ }
41
+
42
+ function utf8Bytes(input: string): Uint8Array {
43
+ const out: number[] = [];
44
+ for (let i = 0; i < input.length; i += 1) {
45
+ let codePoint = input.codePointAt(i)!;
46
+ if (codePoint > 0xffff) {
47
+ i += 1;
48
+ }
49
+ if (codePoint < 0x80) {
50
+ out.push(codePoint);
51
+ } else if (codePoint < 0x800) {
52
+ out.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f));
53
+ } else if (codePoint < 0x10000) {
54
+ out.push(
55
+ 0xe0 | (codePoint >> 12),
56
+ 0x80 | ((codePoint >> 6) & 0x3f),
57
+ 0x80 | (codePoint & 0x3f),
58
+ );
59
+ } else {
60
+ out.push(
61
+ 0xf0 | (codePoint >> 18),
62
+ 0x80 | ((codePoint >> 12) & 0x3f),
63
+ 0x80 | ((codePoint >> 6) & 0x3f),
64
+ 0x80 | (codePoint & 0x3f),
65
+ );
66
+ }
67
+ }
68
+ return Uint8Array.from(out);
69
+ }
70
+
71
+ export function utf8ByteLength(input: string): number {
72
+ return utf8Bytes(input).length;
73
+ }
74
+
75
+ export function sha256Hex(input: string): string {
76
+ const bytes = utf8Bytes(input);
77
+ const bitLength = bytes.length * 8;
78
+ const paddedLength = (((bytes.length + 9) >> 6) + 1) << 6;
79
+ const padded = new Uint8Array(paddedLength);
80
+ padded.set(bytes);
81
+ padded[bytes.length] = 0x80;
82
+
83
+ const view = new DataView(padded.buffer);
84
+ view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false);
85
+ view.setUint32(paddedLength - 4, bitLength >>> 0, false);
86
+
87
+ const h = new Uint32Array([
88
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
89
+ 0x5be0cd19,
90
+ ]);
91
+ const w = new Uint32Array(64);
92
+
93
+ for (let offset = 0; offset < paddedLength; offset += 64) {
94
+ for (let i = 0; i < 16; i += 1) {
95
+ w[i] = view.getUint32(offset + i * 4, false);
96
+ }
97
+ for (let i = 16; i < 64; i += 1) {
98
+ const s0 = rotr(w[i - 15]!, 7) ^ rotr(w[i - 15]!, 18) ^ (w[i - 15]! >>> 3);
99
+ const s1 = rotr(w[i - 2]!, 17) ^ rotr(w[i - 2]!, 19) ^ (w[i - 2]! >>> 10);
100
+ w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0;
101
+ }
102
+
103
+ let [a, b, c, d, e, f, g, hh] = [h[0]!, h[1]!, h[2]!, h[3]!, h[4]!, h[5]!, h[6]!, h[7]!];
104
+
105
+ for (let i = 0; i < 64; i += 1) {
106
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
107
+ const ch = (e & f) ^ (~e & g);
108
+ const temp1 = (hh + S1 + ch + K[i]! + w[i]!) >>> 0;
109
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
110
+ const maj = (a & b) ^ (a & c) ^ (b & c);
111
+ const temp2 = (S0 + maj) >>> 0;
112
+
113
+ hh = g;
114
+ g = f;
115
+ f = e;
116
+ e = (d + temp1) >>> 0;
117
+ d = c;
118
+ c = b;
119
+ b = a;
120
+ a = (temp1 + temp2) >>> 0;
121
+ }
122
+
123
+ h[0] = (h[0]! + a) >>> 0;
124
+ h[1] = (h[1]! + b) >>> 0;
125
+ h[2] = (h[2]! + c) >>> 0;
126
+ h[3] = (h[3]! + d) >>> 0;
127
+ h[4] = (h[4]! + e) >>> 0;
128
+ h[5] = (h[5]! + f) >>> 0;
129
+ h[6] = (h[6]! + g) >>> 0;
130
+ h[7] = (h[7]! + hh) >>> 0;
131
+ }
132
+
133
+ return Array.from(h, value => value.toString(16).padStart(8, '0')).join('');
134
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Scope descriptor normalization.
3
+ *
4
+ * The package normalizes and validates; it never repairs. A component it
5
+ * cannot represent safely is an error (FR-013), because silently sanitizing
6
+ * one is how two different descriptors end up sharing a directory — which
7
+ * would be a cross-scope leak dressed up as a convenience.
8
+ */
9
+
10
+ import type { ScopeDescriptor, ScopeKind } from '../contract/scope';
11
+
12
+ /**
13
+ * Separates components in the canonical form.
14
+ *
15
+ * NUL rather than a printable character: a component containing the separator
16
+ * could forge another descriptor's canonical string, and every printable
17
+ * candidate occurs in legitimate identity data. Tenant names contain spaces,
18
+ * identifiers contain colons and slashes, emails contain "@" and ".". NUL
19
+ * occurs in none of them, so reserving it costs consumers nothing.
20
+ */
21
+ export const COMPONENT_SEPARATOR = '\u0000';
22
+
23
+ /**
24
+ * Control characters are rejected wholesale rather than only the separator.
25
+ * They have no place in an identity component, and they would be trouble in a
26
+ * log line or a diagnostic even where they could not forge a canonical
27
+ * string.
28
+ */
29
+ // eslint-disable-next-line no-control-regex
30
+ export const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
31
+
32
+ export const MAX_COMPONENT_BYTES = 512;
33
+
34
+ /** Fixed order, so the canonical form is stable across callers. */
35
+ export const COMPONENT_ORDER = [
36
+ 'kind',
37
+ 'id',
38
+ 'tenant',
39
+ 'app',
40
+ 'environment',
41
+ 'namespace',
42
+ ] as const;
43
+
44
+ export const SCOPE_KINDS: readonly ScopeKind[] = [
45
+ 'guest',
46
+ 'identity',
47
+ 'tenant',
48
+ 'workspace',
49
+ 'custom',
50
+ ];
51
+
52
+ /**
53
+ * NFC, trimmed, case preserved.
54
+ *
55
+ * Case is preserved deliberately: the package does not know whether a
56
+ * consumer's identity system is case-sensitive, and folding case would merge
57
+ * two scopes the app considers distinct.
58
+ */
59
+ export function normalizeComponent(value: string): string {
60
+ return value.normalize('NFC').trim();
61
+ }
62
+
63
+ export type NormalizedScope = {
64
+ kind: ScopeKind;
65
+ components: Record<string, string>;
66
+ canonical: string;
67
+ };
68
+
69
+ /** Normalize without validating. Callers must validate first. */
70
+ export function normalizeDescriptor(descriptor: ScopeDescriptor): NormalizedScope {
71
+ const components: Record<string, string> = {};
72
+ const parts: string[] = [];
73
+
74
+ for (const key of COMPONENT_ORDER) {
75
+ const raw = descriptor[key as keyof ScopeDescriptor];
76
+ if (typeof raw !== 'string' || raw.length === 0) {
77
+ parts.push('');
78
+ continue;
79
+ }
80
+ const normalized = normalizeComponent(raw);
81
+ components[key] = normalized;
82
+ parts.push(normalized);
83
+ }
84
+
85
+ return {
86
+ kind: descriptor.kind,
87
+ components,
88
+ canonical: parts.join(COMPONENT_SEPARATOR),
89
+ };
90
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Scope validation and resolution.
3
+ *
4
+ * Validate, never repair (FR-013). The package never invents, derives, or
5
+ * defaults an identity of its own (FR-008).
6
+ */
7
+
8
+ import { DiagnosticCodes } from '../contract/errors';
9
+ import type { ResolvedScope, ScopeDescriptor } from '../contract/scope';
10
+ import { raise } from '../diagnostics/errors';
11
+ import { sha256Hex, utf8ByteLength } from './hash';
12
+ import {
13
+ COMPONENT_ORDER,
14
+ CONTROL_CHARACTER,
15
+ MAX_COMPONENT_BYTES,
16
+ SCOPE_KINDS,
17
+ normalizeComponent,
18
+ normalizeDescriptor,
19
+ } from './normalize';
20
+
21
+ function invalid(reason: string): never {
22
+ // The reason names the *component*, never its value — naming the value
23
+ // would put a scope component into an error, which FR-014 forbids.
24
+ raise(DiagnosticCodes.INVALID_SCOPE, { message: reason });
25
+ }
26
+
27
+ function validateComponent(key: string, raw: unknown): void {
28
+ if (raw === undefined || raw === null) {
29
+ return;
30
+ }
31
+ if (typeof raw !== 'string') {
32
+ invalid(`scope component ${key} must be a string`);
33
+ }
34
+ const normalized = normalizeComponent(raw);
35
+ if (normalized.length === 0) {
36
+ invalid(`scope component ${key} is empty after normalization`);
37
+ }
38
+ if (CONTROL_CHARACTER.test(normalized)) {
39
+ invalid(`scope component ${key} contains a control character`);
40
+ }
41
+ if (utf8ByteLength(normalized) > MAX_COMPONENT_BYTES) {
42
+ invalid(`scope component ${key} exceeds the maximum length`);
43
+ }
44
+ }
45
+
46
+ export function validateDescriptor(descriptor: ScopeDescriptor | undefined | null): void {
47
+ if (!descriptor || typeof descriptor !== 'object') {
48
+ invalid('scope descriptor is required');
49
+ }
50
+ if (!SCOPE_KINDS.includes(descriptor.kind)) {
51
+ invalid('scope kind is not recognised');
52
+ }
53
+
54
+ // Guest is the one kind with no identity, by design: an anonymous session
55
+ // still gets its own isolated scope rather than sharing a default.
56
+ if (descriptor.kind !== 'guest') {
57
+ const id = descriptor.id;
58
+ if (typeof id !== 'string' || normalizeComponent(id).length === 0) {
59
+ invalid('scope id is required for this kind');
60
+ }
61
+ }
62
+
63
+ for (const key of COMPONENT_ORDER) {
64
+ if (key === 'kind') {
65
+ continue;
66
+ }
67
+ validateComponent(key, descriptor[key as keyof ScopeDescriptor]);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Deterministic: identical descriptors always resolve to the same scope, and
73
+ * descriptors differing in any component always resolve to different ones
74
+ * (FR-010).
75
+ */
76
+ export function resolveScope(descriptor: ScopeDescriptor): ResolvedScope {
77
+ validateDescriptor(descriptor);
78
+ const normalized = normalizeDescriptor(descriptor);
79
+ return {
80
+ key: sha256Hex(normalized.canonical),
81
+ kind: descriptor.kind,
82
+ };
83
+ }