@nacre.work/core 0.0.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.
- package/README.md +3 -0
- package/dist/authz/cache.d.ts +54 -0
- package/dist/authz/cache.d.ts.map +1 -0
- package/dist/authz/cache.js +63 -0
- package/dist/authz/cache.js.map +1 -0
- package/dist/authz/filter.d.ts +60 -0
- package/dist/authz/filter.d.ts.map +1 -0
- package/dist/authz/filter.js +54 -0
- package/dist/authz/filter.js.map +1 -0
- package/dist/authz/index.d.ts +15 -0
- package/dist/authz/index.d.ts.map +1 -0
- package/dist/authz/index.js +9 -0
- package/dist/authz/index.js.map +1 -0
- package/dist/authz/permissions.d.ts +20 -0
- package/dist/authz/permissions.d.ts.map +1 -0
- package/dist/authz/permissions.js +43 -0
- package/dist/authz/permissions.js.map +1 -0
- package/dist/authz/principals.d.ts +34 -0
- package/dist/authz/principals.d.ts.map +1 -0
- package/dist/authz/principals.js +42 -0
- package/dist/authz/principals.js.map +1 -0
- package/dist/authz/reference.d.ts +25 -0
- package/dist/authz/reference.d.ts.map +1 -0
- package/dist/authz/reference.js +34 -0
- package/dist/authz/reference.js.map +1 -0
- package/dist/authz/resolve.d.ts +45 -0
- package/dist/authz/resolve.d.ts.map +1 -0
- package/dist/authz/resolve.js +98 -0
- package/dist/authz/resolve.js.map +1 -0
- package/dist/authz/scope-tree.d.ts +52 -0
- package/dist/authz/scope-tree.d.ts.map +1 -0
- package/dist/authz/scope-tree.js +56 -0
- package/dist/authz/scope-tree.js.map +1 -0
- package/dist/authz/store.d.ts +50 -0
- package/dist/authz/store.d.ts.map +1 -0
- package/dist/authz/store.js +115 -0
- package/dist/authz/store.js.map +1 -0
- package/dist/authz/tags.d.ts +40 -0
- package/dist/authz/tags.d.ts.map +1 -0
- package/dist/authz/tags.js +48 -0
- package/dist/authz/tags.js.map +1 -0
- package/dist/config.d.ts +73 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +157 -0
- package/dist/config.js.map +1 -0
- package/dist/db/client.d.ts +38 -0
- package/dist/db/client.d.ts.map +1 -0
- package/dist/db/client.js +34 -0
- package/dist/db/client.js.map +1 -0
- package/dist/db/migrate.d.ts +32 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +103 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics.d.ts +77 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +179 -0
- package/dist/metrics.js.map +1 -0
- package/dist/migrate-main.d.ts +2 -0
- package/dist/migrate-main.d.ts.map +1 -0
- package/dist/migrate-main.js +34 -0
- package/dist/migrate-main.js.map +1 -0
- package/dist/migrations/0001_init.sql +226 -0
- package/dist/migrations/0002_rls_coverage.sql +128 -0
- package/dist/migrations/0003_groups_version.sql +48 -0
- package/dist/migrations/0004_acl_version.sql +26 -0
- package/dist/migrations/0005_grants_bump_version.sql +38 -0
- package/dist/migrations/0006_layer_slug_unique.sql +47 -0
- package/dist/migrations/0007_index_lease.sql +42 -0
- package/dist/observability.d.ts +11 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +55 -0
- package/dist/observability.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/dist/vector/query.d.ts +140 -0
- package/dist/vector/query.d.ts.map +1 -0
- package/dist/vector/query.js +94 -0
- package/dist/vector/query.js.map +1 -0
- package/dist/vector/search.d.ts +62 -0
- package/dist/vector/search.d.ts.map +1 -0
- package/dist/vector/search.js +98 -0
- package/dist/vector/search.js.map +1 -0
- package/package.json +39 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { principalRef } from '../types.js';
|
|
2
|
+
import { satisfies } from './permissions.js';
|
|
3
|
+
/**
|
|
4
|
+
* Turn grants into the set of things the caller may reach.
|
|
5
|
+
*
|
|
6
|
+
* Where this differs from `reference.ts`: the reference answers "may they touch
|
|
7
|
+
* this one scope?", which is the question the rules are written in. A search
|
|
8
|
+
* needs the inverse — the whole permitted set, up front, so it can go into the
|
|
9
|
+
* query as a pre-filter. Computing it by asking the reference per document
|
|
10
|
+
* would be a post-filter with extra steps.
|
|
11
|
+
*
|
|
12
|
+
* The property-based test exists because these two are different algorithms
|
|
13
|
+
* answering the same question, and only one of them is obviously correct.
|
|
14
|
+
*/
|
|
15
|
+
export function resolve(input, permission) {
|
|
16
|
+
const { orgId, role, principals, grants, tree } = input;
|
|
17
|
+
// Rule 2, before anything else: platform_admin reads no documents.
|
|
18
|
+
if (role === 'platform_admin')
|
|
19
|
+
return { kind: 'none' };
|
|
20
|
+
// Rule 3, with rule 7 making admin cover read and write.
|
|
21
|
+
if (role === 'org_admin')
|
|
22
|
+
return { kind: 'all' };
|
|
23
|
+
// Rule 1 is a precondition. A grant from another tenant is discarded, not
|
|
24
|
+
// weighed. Rules 6 and 7 are entirely inside `satisfies`.
|
|
25
|
+
const applicable = grants.filter((g) => g.orgId === orgId &&
|
|
26
|
+
principals.has(principalRef(g.principal)) &&
|
|
27
|
+
satisfies(g.permission, permission));
|
|
28
|
+
const denied = { workspaces: new Set(), layers: new Set(), docs: new Set() };
|
|
29
|
+
const allowed = { workspaces: new Set(), layers: new Set(), docs: new Set() };
|
|
30
|
+
for (const g of applicable) {
|
|
31
|
+
const side = g.effect === 'deny' ? denied : allowed;
|
|
32
|
+
switch (g.scope.type) {
|
|
33
|
+
case 'workspace':
|
|
34
|
+
side.workspaces.add(g.scope.id);
|
|
35
|
+
break;
|
|
36
|
+
case 'layer':
|
|
37
|
+
side.layers.add(g.scope.id);
|
|
38
|
+
break;
|
|
39
|
+
case 'document':
|
|
40
|
+
side.docs.add(g.scope.id);
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Rule 4 downward: a denied workspace denies each of its layers.
|
|
45
|
+
const deniedLayers = new Set(denied.layers);
|
|
46
|
+
for (const workspace of denied.workspaces) {
|
|
47
|
+
for (const layer of tree.layersOf(workspace))
|
|
48
|
+
deniedLayers.add(layer);
|
|
49
|
+
}
|
|
50
|
+
// Rule 5: subtract, never compare depth. A layer allowed explicitly under a
|
|
51
|
+
// denied workspace stays denied — "most specific wins" would allow it, and
|
|
52
|
+
// that is a different model from the one specified.
|
|
53
|
+
const layers = new Set();
|
|
54
|
+
for (const workspace of allowed.workspaces) {
|
|
55
|
+
for (const layer of tree.layersOf(workspace)) {
|
|
56
|
+
if (!deniedLayers.has(layer))
|
|
57
|
+
layers.add(layer);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const layer of allowed.layers) {
|
|
61
|
+
if (!deniedLayers.has(layer))
|
|
62
|
+
layers.add(layer);
|
|
63
|
+
}
|
|
64
|
+
// Document-scoped allows. The pseudocode in the specification subtracts only
|
|
65
|
+
// document-scoped denies here, which loses the truth-table row where a deny
|
|
66
|
+
// on the workspace beats an allow on a document beneath it. Ancestors are
|
|
67
|
+
// checked instead — see the note in docs/authz.md.
|
|
68
|
+
const extraDocs = new Set();
|
|
69
|
+
for (const doc of allowed.docs) {
|
|
70
|
+
if (denied.docs.has(doc))
|
|
71
|
+
continue;
|
|
72
|
+
const layer = tree.layerOf(doc);
|
|
73
|
+
// An unplaceable document cannot be checked against its ancestors, so it
|
|
74
|
+
// is denied. Rule I3: a permission that cannot be evaluated is refused.
|
|
75
|
+
if (layer === undefined)
|
|
76
|
+
continue;
|
|
77
|
+
if (deniedLayers.has(layer))
|
|
78
|
+
continue;
|
|
79
|
+
const workspace = tree.workspaceOf(layer);
|
|
80
|
+
if (workspace !== undefined && denied.workspaces.has(workspace))
|
|
81
|
+
continue;
|
|
82
|
+
if (layers.has(layer))
|
|
83
|
+
continue; // already covered by the layer grant
|
|
84
|
+
extraDocs.add(doc);
|
|
85
|
+
}
|
|
86
|
+
if (layers.size === 0 && extraDocs.size === 0)
|
|
87
|
+
return { kind: 'none' };
|
|
88
|
+
return {
|
|
89
|
+
kind: 'scoped',
|
|
90
|
+
layers: [...layers].sort(),
|
|
91
|
+
extraDocs: [...extraDocs].sort(),
|
|
92
|
+
// Kept even when the document sits outside every allowed layer: it costs
|
|
93
|
+
// one clause and it is the difference between "excluded" and "excluded
|
|
94
|
+
// unless someone later widens the layer set".
|
|
95
|
+
deniedDocs: [...denied.docs].sort(),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=resolve.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve.js","sourceRoot":"","sources":["../../authz/resolve.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Scope } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A read-only view of the scope tree for one organization: workspace → layer →
|
|
4
|
+
* document.
|
|
5
|
+
*
|
|
6
|
+
* The resolver needs this to apply rules 4 and 5. Inheritance runs downward, so
|
|
7
|
+
* a grant on a workspace reaches its layers; deny beats allow at any depth, so
|
|
8
|
+
* an explicit allow on a document has to be checked against denies on its
|
|
9
|
+
* ancestors. Neither is decidable from the grants alone.
|
|
10
|
+
*
|
|
11
|
+
* Only documents named in a grant ever need placing, so `layerOf` is called a
|
|
12
|
+
* handful of times per query, not once per document in the index. Nothing here
|
|
13
|
+
* enumerates documents.
|
|
14
|
+
*/
|
|
15
|
+
export interface ScopeTree {
|
|
16
|
+
/** Layers directly under a workspace. Empty for an unknown workspace. */
|
|
17
|
+
layersOf(workspaceId: string): readonly string[];
|
|
18
|
+
/** The workspace a layer belongs to, or undefined if the layer is unknown. */
|
|
19
|
+
workspaceOf(layerId: string): string | undefined;
|
|
20
|
+
/** The layer a document belongs to, or undefined if the document is unknown. */
|
|
21
|
+
layerOf(documentId: string): string | undefined;
|
|
22
|
+
}
|
|
23
|
+
export interface ScopeTreeData {
|
|
24
|
+
/** workspace id → layer ids */
|
|
25
|
+
readonly layers: Readonly<Record<string, readonly string[]>>;
|
|
26
|
+
/** document id → layer id */
|
|
27
|
+
readonly documents: Readonly<Record<string, string>>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* In-memory tree, used by tests and by anything holding a small catalog.
|
|
31
|
+
*
|
|
32
|
+
* An unknown id resolves to undefined rather than throwing. That is not
|
|
33
|
+
* laziness: a grant can outlive the layer it referenced, and the resolver's
|
|
34
|
+
* behaviour for an unplaceable scope has to be *deny*, which reads more clearly
|
|
35
|
+
* as "we could not place it, so it grants nothing" than as an exception the
|
|
36
|
+
* caller might catch and continue past.
|
|
37
|
+
*/
|
|
38
|
+
export declare class MemoryScopeTree implements ScopeTree {
|
|
39
|
+
#private;
|
|
40
|
+
constructor(data: ScopeTreeData);
|
|
41
|
+
layersOf(workspaceId: string): readonly string[];
|
|
42
|
+
workspaceOf(layerId: string): string | undefined;
|
|
43
|
+
layerOf(documentId: string): string | undefined;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The chain from a scope up to the organization root, the scope itself first.
|
|
47
|
+
*
|
|
48
|
+
* Used to answer "is any ancestor of this denied?", which is what makes deny
|
|
49
|
+
* absolute rather than nearest-wins.
|
|
50
|
+
*/
|
|
51
|
+
export declare function ancestry(scope: Scope, tree: ScopeTree): readonly Scope[];
|
|
52
|
+
//# sourceMappingURL=scope-tree.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scope-tree.d.ts","sourceRoot":"","sources":["../../authz/scope-tree.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAA;AAExC;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,SAAS;IACxB,yEAAyE;IACzE,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAA;IAChD,8EAA8E;IAC9E,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;IAChD,gFAAgF;IAChF,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;CAChD;AAED,MAAM,WAAW,aAAa;IAC5B,+BAA+B;IAC/B,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAA;IAC5D,6BAA6B;IAC7B,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CACrD;AAED;;;;;;;;GAQG;AACH,qBAAa,eAAgB,YAAW,SAAS;;gBAKnC,IAAI,EAAE,aAAa;IAQ/B,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE;IAIhD,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIhD,OAAO,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;CAGhD;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,GAAG,SAAS,KAAK,EAAE,CAgBxE"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory tree, used by tests and by anything holding a small catalog.
|
|
3
|
+
*
|
|
4
|
+
* An unknown id resolves to undefined rather than throwing. That is not
|
|
5
|
+
* laziness: a grant can outlive the layer it referenced, and the resolver's
|
|
6
|
+
* behaviour for an unplaceable scope has to be *deny*, which reads more clearly
|
|
7
|
+
* as "we could not place it, so it grants nothing" than as an exception the
|
|
8
|
+
* caller might catch and continue past.
|
|
9
|
+
*/
|
|
10
|
+
export class MemoryScopeTree {
|
|
11
|
+
#layersByWorkspace;
|
|
12
|
+
#workspaceByLayer = new Map();
|
|
13
|
+
#layerByDocument;
|
|
14
|
+
constructor(data) {
|
|
15
|
+
this.#layersByWorkspace = new Map(Object.entries(data.layers));
|
|
16
|
+
for (const [workspace, layers] of this.#layersByWorkspace) {
|
|
17
|
+
for (const layer of layers)
|
|
18
|
+
this.#workspaceByLayer.set(layer, workspace);
|
|
19
|
+
}
|
|
20
|
+
this.#layerByDocument = new Map(Object.entries(data.documents));
|
|
21
|
+
}
|
|
22
|
+
layersOf(workspaceId) {
|
|
23
|
+
return this.#layersByWorkspace.get(workspaceId) ?? [];
|
|
24
|
+
}
|
|
25
|
+
workspaceOf(layerId) {
|
|
26
|
+
return this.#workspaceByLayer.get(layerId);
|
|
27
|
+
}
|
|
28
|
+
layerOf(documentId) {
|
|
29
|
+
return this.#layerByDocument.get(documentId);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The chain from a scope up to the organization root, the scope itself first.
|
|
34
|
+
*
|
|
35
|
+
* Used to answer "is any ancestor of this denied?", which is what makes deny
|
|
36
|
+
* absolute rather than nearest-wins.
|
|
37
|
+
*/
|
|
38
|
+
export function ancestry(scope, tree) {
|
|
39
|
+
switch (scope.type) {
|
|
40
|
+
case 'workspace':
|
|
41
|
+
return [scope];
|
|
42
|
+
case 'layer': {
|
|
43
|
+
const workspace = tree.workspaceOf(scope.id);
|
|
44
|
+
return workspace === undefined
|
|
45
|
+
? [scope]
|
|
46
|
+
: [scope, { type: 'workspace', id: workspace }];
|
|
47
|
+
}
|
|
48
|
+
case 'document': {
|
|
49
|
+
const layer = tree.layerOf(scope.id);
|
|
50
|
+
if (layer === undefined)
|
|
51
|
+
return [scope];
|
|
52
|
+
return [scope, ...ancestry({ type: 'layer', id: layer }, tree)];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=scope-tree.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scope-tree.js","sourceRoot":"","sources":["../../authz/scope-tree.ts"],"names":[],"mappings":"AA+BA;;;;;;;;GAQG;AACH,MAAM,OAAO,eAAe;IACjB,kBAAkB,CAAgC;IAClD,iBAAiB,GAAG,IAAI,GAAG,EAAkB,CAAA;IAC7C,gBAAgB,CAAqB;IAE9C,YAAY,IAAmB;QAC7B,IAAI,CAAC,kBAAkB,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAA;QAC9D,KAAK,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,IAAI,IAAI,CAAC,kBAAkB,EAAE,CAAC;YAC1D,KAAK,MAAM,KAAK,IAAI,MAAM;gBAAE,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,KAAK,EAAE,SAAS,CAAC,CAAA;QAC1E,CAAC;QACD,IAAI,CAAC,gBAAgB,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAA;IACjE,CAAC;IAED,QAAQ,CAAC,WAAmB;QAC1B,OAAO,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,WAAW,CAAC,IAAI,EAAE,CAAA;IACvD,CAAC;IAED,WAAW,CAAC,OAAe;QACzB,OAAO,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;IAC5C,CAAC;IAED,OAAO,CAAC,UAAkB;QACxB,OAAO,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,UAAU,CAAC,CAAA;IAC9C,CAAC;CACF;AAED;;;;;GAKG;AACH,MAAM,UAAU,QAAQ,CAAC,KAAY,EAAE,IAAe;IACpD,QAAQ,KAAK,CAAC,IAAI,EAAE,CAAC;QACnB,KAAK,WAAW;YACd,OAAO,CAAC,KAAK,CAAC,CAAA;QAChB,KAAK,OAAO,CAAC,CAAC,CAAC;YACb,MAAM,SAAS,GAAG,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;YAC5C,OAAO,SAAS,KAAK,SAAS;gBAC5B,CAAC,CAAC,CAAC,KAAK,CAAC;gBACT,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,EAAE,SAAS,EAAE,CAAC,CAAA;QACnD,CAAC;QACD,KAAK,UAAU,CAAC,CAAC,CAAC;YAChB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;YACpC,IAAI,KAAK,KAAK,SAAS;gBAAE,OAAO,CAAC,KAAK,CAAC,CAAA;YACvC,OAAO,CAAC,KAAK,EAAE,GAAG,QAAQ,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,IAAI,CAAC,CAAC,CAAA;QACjE,CAAC;IACH,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PoolClient } from 'pg';
|
|
2
|
+
import type { Grant, PrincipalRef } from '../types.js';
|
|
3
|
+
import type { GroupGraph } from './principals.js';
|
|
4
|
+
import { type ScopeTree } from './scope-tree.js';
|
|
5
|
+
/**
|
|
6
|
+
* Load the grants that could bear on a request.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately *not* filtered by permission in SQL. `satisfies` decides which
|
|
9
|
+
* grants matter, and duplicating rules 6 and 7 into a `WHERE` clause would mean
|
|
10
|
+
* two places to keep in step — with the SQL one being the copy nobody tests
|
|
11
|
+
* against the truth table. The row count here is bounded by the caller's
|
|
12
|
+
* principals, not by the size of the index.
|
|
13
|
+
*
|
|
14
|
+
* The connection must already be scoped with `withOrg`, so RLS is a second
|
|
15
|
+
* bound on top of the explicit `org_id` below.
|
|
16
|
+
*/
|
|
17
|
+
export declare function loadGrants(client: PoolClient, orgId: string, principals: ReadonlySet<PrincipalRef>): Promise<readonly Grant[]>;
|
|
18
|
+
/**
|
|
19
|
+
* Build the scope tree the resolver needs.
|
|
20
|
+
*
|
|
21
|
+
* Only the documents named in the grants are placed — the tree never
|
|
22
|
+
* enumerates the index. Deleted layers and documents are excluded, so a grant
|
|
23
|
+
* pointing at one becomes unplaceable and is therefore refused, which is the
|
|
24
|
+
* behaviour rule I3 asks for.
|
|
25
|
+
*/
|
|
26
|
+
export declare function loadScopeTree(client: PoolClient, orgId: string, documentIds: readonly string[]): Promise<ScopeTree>;
|
|
27
|
+
/**
|
|
28
|
+
* Group membership, read straight from the join table.
|
|
29
|
+
*
|
|
30
|
+
* Nesting is resolved by `effectivePrincipals`, not here, and not in SQL. A
|
|
31
|
+
* recursive CTE would be faster and would also have to answer what to do about
|
|
32
|
+
* a cycle — `WITH RECURSIVE` needs a cycle clause or it does not terminate, and
|
|
33
|
+
* getting that wrong is a hung connection rather than a wrong answer. Keeping
|
|
34
|
+
* the traversal in one tested place is worth a second round trip.
|
|
35
|
+
*/
|
|
36
|
+
export declare class PostgresGroupGraph implements GroupGraph {
|
|
37
|
+
#private;
|
|
38
|
+
private constructor();
|
|
39
|
+
static load(client: PoolClient, orgId: string): Promise<PostgresGroupGraph>;
|
|
40
|
+
directGroups(principal: PrincipalRef): readonly string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The organization's current groups_version.
|
|
44
|
+
*
|
|
45
|
+
* Read in the same transaction as the grants, so the cache key and the grant
|
|
46
|
+
* set describe the same instant. Reading it separately would leave a window
|
|
47
|
+
* where the version says "fresh" about a set loaded before a change.
|
|
48
|
+
*/
|
|
49
|
+
export declare function loadGroupsVersion(client: PoolClient, orgId: string): Promise<number>;
|
|
50
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../authz/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,IAAI,CAAA;AAEpC,OAAO,KAAK,EAAE,KAAK,EAAyB,YAAY,EAAE,MAAM,aAAa,CAAA;AAC7E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AACjD,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAEjE;;;;;;;;;;;GAWG;AACH,wBAAsB,UAAU,CAC9B,MAAM,EAAE,UAAU,EAClB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,WAAW,CAAC,YAAY,CAAC,GACpC,OAAO,CAAC,SAAS,KAAK,EAAE,CAAC,CAsC3B;AAED;;;;;;;GAOG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,UAAU,EAClB,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,SAAS,MAAM,EAAE,GAC7B,OAAO,CAAC,SAAS,CAAC,CAsBpB;AAED;;;;;;;;GAQG;AACH,qBAAa,kBAAmB,YAAW,UAAU;;IAGnD,OAAO;WAIM,IAAI,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAqBjF,YAAY,CAAC,SAAS,EAAE,YAAY,GAAG,SAAS,MAAM,EAAE;CAGzD;AAED;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAY1F"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { MemoryScopeTree } from './scope-tree.js';
|
|
2
|
+
/**
|
|
3
|
+
* Load the grants that could bear on a request.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately *not* filtered by permission in SQL. `satisfies` decides which
|
|
6
|
+
* grants matter, and duplicating rules 6 and 7 into a `WHERE` clause would mean
|
|
7
|
+
* two places to keep in step — with the SQL one being the copy nobody tests
|
|
8
|
+
* against the truth table. The row count here is bounded by the caller's
|
|
9
|
+
* principals, not by the size of the index.
|
|
10
|
+
*
|
|
11
|
+
* The connection must already be scoped with `withOrg`, so RLS is a second
|
|
12
|
+
* bound on top of the explicit `org_id` below.
|
|
13
|
+
*/
|
|
14
|
+
export async function loadGrants(client, orgId, principals) {
|
|
15
|
+
const refs = [...principals];
|
|
16
|
+
if (refs.length === 0)
|
|
17
|
+
return [];
|
|
18
|
+
// Split the `{type}:{id}` refs into parallel arrays so the query stays a
|
|
19
|
+
// single indexed lookup rather than a string comparison per row.
|
|
20
|
+
const types = [];
|
|
21
|
+
const ids = [];
|
|
22
|
+
for (const ref of refs) {
|
|
23
|
+
const sep = ref.indexOf(':');
|
|
24
|
+
types.push(ref.slice(0, sep));
|
|
25
|
+
ids.push(ref.slice(sep + 1));
|
|
26
|
+
}
|
|
27
|
+
const { rows } = await client.query(`SELECT principal_type, principal_id, scope_type, scope_id, permission, effect
|
|
28
|
+
FROM grants
|
|
29
|
+
WHERE org_id = $1
|
|
30
|
+
AND (principal_type, principal_id) IN (
|
|
31
|
+
SELECT * FROM unnest($2::text[], $3::uuid[])
|
|
32
|
+
)`, [orgId, types, ids]);
|
|
33
|
+
return rows.map((r) => ({
|
|
34
|
+
orgId,
|
|
35
|
+
principal: { type: r.principal_type, id: r.principal_id },
|
|
36
|
+
scope: { type: r.scope_type, id: r.scope_id },
|
|
37
|
+
permission: r.permission,
|
|
38
|
+
effect: r.effect,
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Build the scope tree the resolver needs.
|
|
43
|
+
*
|
|
44
|
+
* Only the documents named in the grants are placed — the tree never
|
|
45
|
+
* enumerates the index. Deleted layers and documents are excluded, so a grant
|
|
46
|
+
* pointing at one becomes unplaceable and is therefore refused, which is the
|
|
47
|
+
* behaviour rule I3 asks for.
|
|
48
|
+
*/
|
|
49
|
+
export async function loadScopeTree(client, orgId, documentIds) {
|
|
50
|
+
const { rows: layerRows } = await client.query(`SELECT id, workspace_id FROM layers WHERE org_id = $1 AND deleted_at IS NULL`, [orgId]);
|
|
51
|
+
const layers = {};
|
|
52
|
+
for (const row of layerRows) {
|
|
53
|
+
;
|
|
54
|
+
(layers[row.workspace_id] ??= []).push(row.id);
|
|
55
|
+
}
|
|
56
|
+
const documents = {};
|
|
57
|
+
if (documentIds.length > 0) {
|
|
58
|
+
const { rows } = await client.query(`SELECT id, layer_id FROM documents
|
|
59
|
+
WHERE org_id = $1 AND deleted_at IS NULL AND id = ANY($2::uuid[])`, [orgId, [...documentIds]]);
|
|
60
|
+
for (const row of rows)
|
|
61
|
+
documents[row.id] = row.layer_id;
|
|
62
|
+
}
|
|
63
|
+
return new MemoryScopeTree({ layers, documents });
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Group membership, read straight from the join table.
|
|
67
|
+
*
|
|
68
|
+
* Nesting is resolved by `effectivePrincipals`, not here, and not in SQL. A
|
|
69
|
+
* recursive CTE would be faster and would also have to answer what to do about
|
|
70
|
+
* a cycle — `WITH RECURSIVE` needs a cycle clause or it does not terminate, and
|
|
71
|
+
* getting that wrong is a hung connection rather than a wrong answer. Keeping
|
|
72
|
+
* the traversal in one tested place is worth a second round trip.
|
|
73
|
+
*/
|
|
74
|
+
export class PostgresGroupGraph {
|
|
75
|
+
#edges;
|
|
76
|
+
constructor(edges) {
|
|
77
|
+
this.#edges = edges;
|
|
78
|
+
}
|
|
79
|
+
static async load(client, orgId) {
|
|
80
|
+
const { rows } = await client.query(`SELECT group_id, member_user, member_group FROM group_members WHERE org_id = $1`, [orgId]);
|
|
81
|
+
const edges = new Map();
|
|
82
|
+
for (const row of rows) {
|
|
83
|
+
const member = row.member_user
|
|
84
|
+
? `user:${row.member_user}`
|
|
85
|
+
: row.member_group
|
|
86
|
+
? `group:${row.member_group}`
|
|
87
|
+
: null;
|
|
88
|
+
if (member === null)
|
|
89
|
+
continue;
|
|
90
|
+
(edges.get(member) ?? edges.set(member, []).get(member)).push(row.group_id);
|
|
91
|
+
}
|
|
92
|
+
return new PostgresGroupGraph(edges);
|
|
93
|
+
}
|
|
94
|
+
directGroups(principal) {
|
|
95
|
+
return this.#edges.get(principal) ?? [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The organization's current groups_version.
|
|
100
|
+
*
|
|
101
|
+
* Read in the same transaction as the grants, so the cache key and the grant
|
|
102
|
+
* set describe the same instant. Reading it separately would leave a window
|
|
103
|
+
* where the version says "fresh" about a set loaded before a change.
|
|
104
|
+
*/
|
|
105
|
+
export async function loadGroupsVersion(client, orgId) {
|
|
106
|
+
const { rows } = await client.query('SELECT groups_version FROM organizations WHERE id = $1', [orgId]);
|
|
107
|
+
const value = rows[0]?.groups_version;
|
|
108
|
+
if (value === undefined) {
|
|
109
|
+
// No organization means no permissions to evaluate. Rule I3: deny rather
|
|
110
|
+
// than default, and a version of 0 is a key nothing else will collide with.
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
return Number(value);
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../authz/store.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { PrincipalRef } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* ACL tags: the denormalization described in docs/authz.md 3.6.
|
|
4
|
+
*
|
|
5
|
+
* Each indexed chunk carries hashes of the principals allowed to read it, so a
|
|
6
|
+
* filter can narrow on the payload instead of joining back to Postgres. The
|
|
7
|
+
* thing to keep in view is what it is *not*:
|
|
8
|
+
*
|
|
9
|
+
* **`acl_tags` is a cache. The `grants` table is the source of truth.** A grant
|
|
10
|
+
* change starts a background recomputation, and until it finishes the tags are
|
|
11
|
+
* stale. That is why the layer bound stays in every query rather than being
|
|
12
|
+
* "simplified" away once tags exist — see the collision note below.
|
|
13
|
+
*/
|
|
14
|
+
/** Bytes kept from each hash. The default matches `NACRE_ACL_TAG_HASH_BYTES`. */
|
|
15
|
+
export declare const DEFAULT_TAG_BYTES = 8;
|
|
16
|
+
/**
|
|
17
|
+
* Hash one principal reference into a tag.
|
|
18
|
+
*
|
|
19
|
+
* Truncation is deliberate and it does collide: 8 bytes over a large enough
|
|
20
|
+
* population will eventually produce two principals with the same tag, and a
|
|
21
|
+
* collision means a false tag match — a chunk appearing to be readable by
|
|
22
|
+
* someone it was not granted to.
|
|
23
|
+
*
|
|
24
|
+
* That is only safe because **the query is also bounded by the allowed
|
|
25
|
+
* `layer_id` list**. A colliding tag inside a layer the caller may already read
|
|
26
|
+
* changes nothing; a colliding tag in a layer they may not read never gets
|
|
27
|
+
* considered, because the layer bound excludes it first. Remove the layer bound
|
|
28
|
+
* and the truncation becomes a leak — which is the single most important thing
|
|
29
|
+
* to know before touching this file.
|
|
30
|
+
*/
|
|
31
|
+
export declare function principalTag(ref: PrincipalRef, bytes?: number): string;
|
|
32
|
+
/**
|
|
33
|
+
* The tags for a set of principals, sorted so the payload is stable.
|
|
34
|
+
*
|
|
35
|
+
* Stability matters for the recomputation job: an unstable order makes every
|
|
36
|
+
* chunk look changed on every pass, and the propagation metric stops meaning
|
|
37
|
+
* anything.
|
|
38
|
+
*/
|
|
39
|
+
export declare function aclTags(principals: Iterable<PrincipalRef>, bytes?: number): readonly string[];
|
|
40
|
+
//# sourceMappingURL=tags.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tags.d.ts","sourceRoot":"","sources":["../../authz/tags.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE/C;;;;;;;;;;;GAWG;AAEH,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,IAAI,CAAA;AAElC;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,YAAY,EAAE,KAAK,GAAE,MAA0B,GAAG,MAAM,CAMzF;AAED;;;;;;GAMG;AACH,wBAAgB,OAAO,CACrB,UAAU,EAAE,QAAQ,CAAC,YAAY,CAAC,EAClC,KAAK,GAAE,MAA0B,GAChC,SAAS,MAAM,EAAE,CAEnB"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* ACL tags: the denormalization described in docs/authz.md 3.6.
|
|
4
|
+
*
|
|
5
|
+
* Each indexed chunk carries hashes of the principals allowed to read it, so a
|
|
6
|
+
* filter can narrow on the payload instead of joining back to Postgres. The
|
|
7
|
+
* thing to keep in view is what it is *not*:
|
|
8
|
+
*
|
|
9
|
+
* **`acl_tags` is a cache. The `grants` table is the source of truth.** A grant
|
|
10
|
+
* change starts a background recomputation, and until it finishes the tags are
|
|
11
|
+
* stale. That is why the layer bound stays in every query rather than being
|
|
12
|
+
* "simplified" away once tags exist — see the collision note below.
|
|
13
|
+
*/
|
|
14
|
+
/** Bytes kept from each hash. The default matches `NACRE_ACL_TAG_HASH_BYTES`. */
|
|
15
|
+
export const DEFAULT_TAG_BYTES = 8;
|
|
16
|
+
/**
|
|
17
|
+
* Hash one principal reference into a tag.
|
|
18
|
+
*
|
|
19
|
+
* Truncation is deliberate and it does collide: 8 bytes over a large enough
|
|
20
|
+
* population will eventually produce two principals with the same tag, and a
|
|
21
|
+
* collision means a false tag match — a chunk appearing to be readable by
|
|
22
|
+
* someone it was not granted to.
|
|
23
|
+
*
|
|
24
|
+
* That is only safe because **the query is also bounded by the allowed
|
|
25
|
+
* `layer_id` list**. A colliding tag inside a layer the caller may already read
|
|
26
|
+
* changes nothing; a colliding tag in a layer they may not read never gets
|
|
27
|
+
* considered, because the layer bound excludes it first. Remove the layer bound
|
|
28
|
+
* and the truncation becomes a leak — which is the single most important thing
|
|
29
|
+
* to know before touching this file.
|
|
30
|
+
*/
|
|
31
|
+
export function principalTag(ref, bytes = DEFAULT_TAG_BYTES) {
|
|
32
|
+
if (bytes < 1 || bytes > 32) {
|
|
33
|
+
throw new Error(`tag width must be between 1 and 32 bytes, got ${bytes}`);
|
|
34
|
+
}
|
|
35
|
+
const digest = createHash('sha256').update(ref, 'utf8').digest();
|
|
36
|
+
return `h:${digest.subarray(0, bytes).toString('hex')}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The tags for a set of principals, sorted so the payload is stable.
|
|
40
|
+
*
|
|
41
|
+
* Stability matters for the recomputation job: an unstable order makes every
|
|
42
|
+
* chunk look changed on every pass, and the propagation metric stops meaning
|
|
43
|
+
* anything.
|
|
44
|
+
*/
|
|
45
|
+
export function aclTags(principals, bytes = DEFAULT_TAG_BYTES) {
|
|
46
|
+
return [...new Set([...principals].map((p) => principalTag(p, bytes)))].sort();
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=tags.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tags.js","sourceRoot":"","sources":["../../authz/tags.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAIxC;;;;;;;;;;;GAWG;AAEH,iFAAiF;AACjF,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAA;AAElC;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,YAAY,CAAC,GAAiB,EAAE,QAAgB,iBAAiB;IAC/E,IAAI,KAAK,GAAG,CAAC,IAAI,KAAK,GAAG,EAAE,EAAE,CAAC;QAC5B,MAAM,IAAI,KAAK,CAAC,iDAAiD,KAAK,EAAE,CAAC,CAAA;IAC3E,CAAC;IACD,MAAM,MAAM,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC,MAAM,EAAE,CAAA;IAChE,OAAO,KAAK,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAA;AACzD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,OAAO,CACrB,UAAkC,EAClC,QAAgB,iBAAiB;IAEjC,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,UAAU,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;AAChF,CAAC"}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration, validated whole at startup.
|
|
3
|
+
*
|
|
4
|
+
* Two rules from docs/config.md, and both are about the same failure:
|
|
5
|
+
*
|
|
6
|
+
* **Validate everything at boot and exit if anything is missing or
|
|
7
|
+
* contradictory.** Not on first use. A service that starts and then fails the
|
|
8
|
+
* first request that needs the missing value looks healthy to an orchestrator,
|
|
9
|
+
* gets traffic, and reports the problem as an error rate.
|
|
10
|
+
*
|
|
11
|
+
* **No silent defaults for secrets or URLs.** A default that quietly points at
|
|
12
|
+
* localhost is how a production deployment ends up talking to nothing and
|
|
13
|
+
* reporting success. Defaults are fine for tunables — a cache TTL has an
|
|
14
|
+
* obviously right value — and never fine for anything naming a host or
|
|
15
|
+
* carrying a credential.
|
|
16
|
+
*/
|
|
17
|
+
export interface Config {
|
|
18
|
+
readonly env: 'development' | 'production';
|
|
19
|
+
readonly canonicalUrl: string;
|
|
20
|
+
readonly logLevel: string;
|
|
21
|
+
readonly logFormat: 'json' | 'text';
|
|
22
|
+
readonly pgUrl: string;
|
|
23
|
+
readonly pgPoolMax: number;
|
|
24
|
+
readonly qdrantUrl: string;
|
|
25
|
+
readonly qdrantApiKey: string | undefined;
|
|
26
|
+
readonly vectorTenancy: 'collection' | 'shared';
|
|
27
|
+
readonly redisUrl: string;
|
|
28
|
+
readonly embeddingEndpoint: string;
|
|
29
|
+
readonly embeddingModel: string;
|
|
30
|
+
readonly embeddingDim: number;
|
|
31
|
+
readonly parserEndpoint: string;
|
|
32
|
+
readonly rerankerEndpoint: string | undefined;
|
|
33
|
+
readonly rerankerEnabled: boolean;
|
|
34
|
+
readonly jwtIssuer: string;
|
|
35
|
+
readonly jwtAudience: string;
|
|
36
|
+
readonly accessTokenTtl: number;
|
|
37
|
+
readonly aclCacheTtl: number;
|
|
38
|
+
readonly aclPropagationSla: number;
|
|
39
|
+
readonly aclTagHashBytes: number;
|
|
40
|
+
/**
|
|
41
|
+
* How long a tombstoned document keeps its vectors before the sweep removes
|
|
42
|
+
* them. A tunable, so a default is fine — and it is not a correctness knob:
|
|
43
|
+
* invariant I5 is held by `deleted = false` in every query, not by this.
|
|
44
|
+
*/
|
|
45
|
+
readonly gcGrace: number;
|
|
46
|
+
/**
|
|
47
|
+
* How long a claimed document may stay claimed before another worker may take
|
|
48
|
+
* it back. Must exceed the slowest legitimate indexing run, because a lease
|
|
49
|
+
* that expires while the work is still going produces two workers on one
|
|
50
|
+
* document — wasteful rather than wrong, since ingest is idempotent, but it
|
|
51
|
+
* is the wrong direction to be wrong in.
|
|
52
|
+
*/
|
|
53
|
+
readonly indexLease: number;
|
|
54
|
+
/**
|
|
55
|
+
* Claims after which a document is failed instead of requeued. Bounded so a
|
|
56
|
+
* document that reliably kills the worker takes itself out of the queue
|
|
57
|
+
* rather than the queue out of service.
|
|
58
|
+
*/
|
|
59
|
+
readonly indexMaxAttempts: number;
|
|
60
|
+
readonly rateSearchPerMin: number;
|
|
61
|
+
readonly rateIngestPerHour: number;
|
|
62
|
+
readonly maxDocumentBytes: number;
|
|
63
|
+
readonly auditRetentionDays: number;
|
|
64
|
+
readonly auditQueryText: boolean;
|
|
65
|
+
}
|
|
66
|
+
export declare class ConfigError extends Error {
|
|
67
|
+
readonly problems: readonly string[];
|
|
68
|
+
constructor(problems: readonly string[]);
|
|
69
|
+
}
|
|
70
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
71
|
+
export declare function loadConfig(env?: Env): Config;
|
|
72
|
+
export {};
|
|
73
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,WAAW,MAAM;IACrB,QAAQ,CAAC,GAAG,EAAE,aAAa,GAAG,YAAY,CAAA;IAC1C,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAA;IAEnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,CAAA;IACzC,QAAQ,CAAC,aAAa,EAAE,YAAY,GAAG,QAAQ,CAAA;IAC/C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IAEzB,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;IAClC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAA;IAC7C,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAA;IAEjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAE/B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;IAClC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;IAEhC;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IAExB;;;;;;OAMG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAE3B;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IAEjC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IACjC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;IAClC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IAEjC,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAA;IACnC,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAA;CACjC;AAED,qBAAa,WAAY,SAAQ,KAAK;IACpC,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;gBAExB,QAAQ,EAAE,SAAS,MAAM,EAAE;CAQxC;AAED,KAAK,GAAG,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;AA8DvD,wBAAgB,UAAU,CAAC,GAAG,GAAE,GAAiB,GAAG,MAAM,CAoFzD"}
|