@orthacms/segments-domain 0.0.0-reserve.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ortha CMS contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # @orthacms/segments-domain
2
+
3
+ Part of [Ortha CMS](https://github.com/ortha-source/ortha-cms).
4
+
5
+ ```sh
6
+ npm install @orthacms/segments-domain
7
+ ```
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Public API of `@orthacms/segments-domain` — who may **read** published
3
+ * content, decided by one pure function over two lists.
4
+ *
5
+ * Deliberately small. A segment is a named set of reader tags; an entry names
6
+ * the segments that may read it and the segments that may not; `canRead` is the
7
+ * whole decision. There is no rule object, no inheritance and no resolution
8
+ * step, because what an editor sets on the entry *is* what a reader gets.
9
+ *
10
+ * Not RBAC, and not `workspace_content`: those answer who may **touch**
11
+ * content, and they already exist.
12
+ */
13
+ export { isOfferedIn, segmentIdsForTags } from './lib/segment';
14
+ export type { Segment } from './lib/segment';
15
+ export { ACCESS_MODE, OPEN_ACCESS, canRead, isOpen, sameAccess } from './lib/entry-access';
16
+ export type { AccessMode, EntryAccess } from './lib/entry-access';
17
+ export { isValidSegment, validateSegment, SEGMENT_ISSUE, SEGMENT_KEY_MAX, SEGMENT_KEY_PATTERN, SEGMENT_LABEL_MAX, SEGMENT_TAG_MAX, SEGMENT_TAGS_MAX } from './lib/validation';
18
+ export type { SegmentDraft, SegmentIssue, SegmentIssues } from './lib/validation';
19
+ export { anonymousSegmentResolver, staticSegmentResolver } from './lib/segment-resolver.port';
20
+ export type { SegmentResolver } from './lib/segment-resolver.port';
21
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAC/D,YAAY,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAE7C,OAAO,EACH,WAAW,EACX,WAAW,EACX,OAAO,EACP,MAAM,EACN,UAAU,EACb,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAIlE,OAAO,EACH,cAAc,EACd,eAAe,EACf,aAAa,EACb,eAAe,EACf,mBAAmB,EACnB,iBAAiB,EACjB,eAAe,EACf,gBAAgB,EACnB,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACR,YAAY,EACZ,YAAY,EACZ,aAAa,EAChB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACH,wBAAwB,EACxB,qBAAqB,EACxB,MAAM,6BAA6B,CAAC;AACrC,YAAY,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ /**
3
+ * Public API of `@orthacms/segments-domain` — who may **read** published
4
+ * content, decided by one pure function over two lists.
5
+ *
6
+ * Deliberately small. A segment is a named set of reader tags; an entry names
7
+ * the segments that may read it and the segments that may not; `canRead` is the
8
+ * whole decision. There is no rule object, no inheritance and no resolution
9
+ * step, because what an editor sets on the entry *is* what a reader gets.
10
+ *
11
+ * Not RBAC, and not `workspace_content`: those answer who may **touch**
12
+ * content, and they already exist.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.staticSegmentResolver = exports.anonymousSegmentResolver = exports.SEGMENT_TAGS_MAX = exports.SEGMENT_TAG_MAX = exports.SEGMENT_LABEL_MAX = exports.SEGMENT_KEY_PATTERN = exports.SEGMENT_KEY_MAX = exports.SEGMENT_ISSUE = exports.validateSegment = exports.isValidSegment = exports.sameAccess = exports.isOpen = exports.canRead = exports.OPEN_ACCESS = exports.ACCESS_MODE = exports.segmentIdsForTags = exports.isOfferedIn = void 0;
16
+ var segment_1 = require("./lib/segment");
17
+ Object.defineProperty(exports, "isOfferedIn", { enumerable: true, get: function () { return segment_1.isOfferedIn; } });
18
+ Object.defineProperty(exports, "segmentIdsForTags", { enumerable: true, get: function () { return segment_1.segmentIdsForTags; } });
19
+ var entry_access_1 = require("./lib/entry-access");
20
+ Object.defineProperty(exports, "ACCESS_MODE", { enumerable: true, get: function () { return entry_access_1.ACCESS_MODE; } });
21
+ Object.defineProperty(exports, "OPEN_ACCESS", { enumerable: true, get: function () { return entry_access_1.OPEN_ACCESS; } });
22
+ Object.defineProperty(exports, "canRead", { enumerable: true, get: function () { return entry_access_1.canRead; } });
23
+ Object.defineProperty(exports, "isOpen", { enumerable: true, get: function () { return entry_access_1.isOpen; } });
24
+ Object.defineProperty(exports, "sameAccess", { enumerable: true, get: function () { return entry_access_1.sameAccess; } });
25
+ // The field rules, read by both the admin's dialog and the server's DTO — two
26
+ // copies of a validation rule is two copies to drift.
27
+ var validation_1 = require("./lib/validation");
28
+ Object.defineProperty(exports, "isValidSegment", { enumerable: true, get: function () { return validation_1.isValidSegment; } });
29
+ Object.defineProperty(exports, "validateSegment", { enumerable: true, get: function () { return validation_1.validateSegment; } });
30
+ Object.defineProperty(exports, "SEGMENT_ISSUE", { enumerable: true, get: function () { return validation_1.SEGMENT_ISSUE; } });
31
+ Object.defineProperty(exports, "SEGMENT_KEY_MAX", { enumerable: true, get: function () { return validation_1.SEGMENT_KEY_MAX; } });
32
+ Object.defineProperty(exports, "SEGMENT_KEY_PATTERN", { enumerable: true, get: function () { return validation_1.SEGMENT_KEY_PATTERN; } });
33
+ Object.defineProperty(exports, "SEGMENT_LABEL_MAX", { enumerable: true, get: function () { return validation_1.SEGMENT_LABEL_MAX; } });
34
+ Object.defineProperty(exports, "SEGMENT_TAG_MAX", { enumerable: true, get: function () { return validation_1.SEGMENT_TAG_MAX; } });
35
+ Object.defineProperty(exports, "SEGMENT_TAGS_MAX", { enumerable: true, get: function () { return validation_1.SEGMENT_TAGS_MAX; } });
36
+ var segment_resolver_port_1 = require("./lib/segment-resolver.port");
37
+ Object.defineProperty(exports, "anonymousSegmentResolver", { enumerable: true, get: function () { return segment_resolver_port_1.anonymousSegmentResolver; } });
38
+ Object.defineProperty(exports, "staticSegmentResolver", { enumerable: true, get: function () { return segment_resolver_port_1.staticSegmentResolver; } });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The **access rule** — what an entry, a type or a workspace declares about who
3
+ * may read it.
4
+ *
5
+ * The shape is deliberately fixed rather than an expression language, and the
6
+ * three constraints below are what buy the whole design its legibility:
7
+ *
8
+ * 1. **Groups are OR-ed, types inside a group are AND-ed.** Disjunctive normal
9
+ * form, and nothing else. `(Acme) OR (EU AND Pro)` is expressible;
10
+ * arbitrary nesting is not.
11
+ * 2. **No nesting.** A group holds conditions, never other groups.
12
+ * 3. **One negation.** Rule-level {@link AccessRule.exclusions} are absolute and
13
+ * sit above every group; a group's own `all-except` narrows only that group.
14
+ *
15
+ * Lift any of them and "why can this reader see this?" stops being a flat list
16
+ * and becomes a proof tree — which is exactly the failure mode this kernel
17
+ * exists to avoid.
18
+ */
19
+ import type { SegmentTypeKey } from './segment-type';
20
+ /** How one segment type constrains one group. */
21
+ export declare const CONDITION_MODE: {
22
+ /** Does not constrain — the type is open in this group. */
23
+ readonly All: "all";
24
+ /** Everyone in the type except the named segments. */
25
+ readonly AllExcept: "all-except";
26
+ /** Only the named segments. */
27
+ readonly Only: "only";
28
+ };
29
+ /** @see CONDITION_MODE */
30
+ export type ConditionMode = (typeof CONDITION_MODE)[keyof typeof CONDITION_MODE];
31
+ /** The authored-only mode: take this level's value from the level above. */
32
+ export declare const INHERIT: "inherit";
33
+ /** A condition as **authored**, which may still defer to the level above. */
34
+ export interface AuthoredCondition {
35
+ readonly mode: ConditionMode | typeof INHERIT;
36
+ /** Segment ids the mode refers to. Empty for `all` and `inherit`. */
37
+ readonly segmentIds: readonly string[];
38
+ }
39
+ /**
40
+ * A condition after inheritance has run: every `inherit` is gone.
41
+ *
42
+ * The two types are kept apart on purpose. {@link evaluate} takes only resolved
43
+ * conditions, so "did anyone forget to run inheritance?" is a compile error
44
+ * rather than a silently permissive decision at runtime.
45
+ */
46
+ export interface ResolvedCondition {
47
+ readonly mode: ConditionMode;
48
+ readonly segmentIds: readonly string[];
49
+ }
50
+ /** One AND-group, as authored: segment type key → condition. */
51
+ export type AuthoredConditionGroup = Readonly<Record<SegmentTypeKey, AuthoredCondition>>;
52
+ /** One AND-group, after inheritance. */
53
+ export interface ResolvedConditionGroup {
54
+ /** Segment type key → condition. A type absent here does not constrain. */
55
+ readonly conditions: Readonly<Record<SegmentTypeKey, ResolvedCondition>>;
56
+ /**
57
+ * Which level contributed this group — what the editor is shown as
58
+ * "inherited from type article". Absent for a group authored on the entry
59
+ * itself.
60
+ */
61
+ readonly source?: AccessLevelName;
62
+ }
63
+ /** What a reader who matched no group is served. */
64
+ export declare const ACCESS_FALLBACK: {
65
+ /** Absent from listings, 404 on a direct read — existence stays secret. */
66
+ readonly Hidden: "hidden";
67
+ /** Returned with a teaser and `requires`, so the client can upsell. */
68
+ readonly Teaser: "teaser";
69
+ /** As `teaser`, plus whatever paywall treatment the client renders. */
70
+ readonly Paywall: "paywall";
71
+ };
72
+ /** @see ACCESS_FALLBACK */
73
+ export type AccessFallback = (typeof ACCESS_FALLBACK)[keyof typeof ACCESS_FALLBACK];
74
+ /** The levels a rule can be attached to, outermost first. */
75
+ export declare const ACCESS_LEVEL: {
76
+ readonly Installation: "installation";
77
+ readonly Workspace: "workspace";
78
+ readonly Type: "type";
79
+ readonly Slice: "slice";
80
+ readonly Entry: "entry";
81
+ };
82
+ /** @see ACCESS_LEVEL */
83
+ export type AccessLevelName = (typeof ACCESS_LEVEL)[keyof typeof ACCESS_LEVEL];
84
+ /** Exclusions, by segment type key. Absolute: they outrank every group. */
85
+ export type Exclusions = Readonly<Record<SegmentTypeKey, readonly string[]>>;
86
+ /** A rule as authored at one level. */
87
+ export interface AuthoredAccessRule {
88
+ /** Segments that never see the content, whatever any group says. */
89
+ readonly exclusions?: Exclusions;
90
+ /** Condition groups, OR-ed. Empty means this level adds no condition. */
91
+ readonly groups?: readonly AuthoredConditionGroup[];
92
+ /**
93
+ * Drop everything inherited from the levels above instead of adding to it.
94
+ *
95
+ * Merging is the default because replacing silently loses a type's own
96
+ * restriction the moment someone edits one entry — but "this one article is
97
+ * genuinely public" has to remain expressible, and this is how.
98
+ */
99
+ readonly detachInherited?: boolean;
100
+ /** Start of the visibility window. `null`/absent = no lower bound. */
101
+ readonly startsAt?: Date | null;
102
+ /** End of the visibility window. `null`/absent = no upper bound. */
103
+ readonly endsAt?: Date | null;
104
+ /** What a reader who matched no group gets. */
105
+ readonly fallback?: AccessFallback;
106
+ }
107
+ /** A rule after inheritance: what {@link evaluate} actually reads. */
108
+ export interface ResolvedAccessRule {
109
+ readonly exclusions: Exclusions;
110
+ /** OR-ed groups. **Empty means unrestricted** — see {@link evaluate}. */
111
+ readonly groups: readonly ResolvedConditionGroup[];
112
+ readonly startsAt: Date | null;
113
+ readonly endsAt: Date | null;
114
+ readonly fallback: AccessFallback;
115
+ }
116
+ /** A rule that restricts nothing — the installation default. */
117
+ export declare const OPEN_ACCESS: ResolvedAccessRule;
118
+ /** Whether a resolved rule constrains anything at all. */
119
+ export declare function isUnrestricted(rule: ResolvedAccessRule): boolean;
120
+ //# sourceMappingURL=access-rule.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access-rule.d.ts","sourceRoot":"","sources":["../../src/lib/access-rule.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAErD,iDAAiD;AACjD,eAAO,MAAM,cAAc;IACvB,2DAA2D;;IAE3D,sDAAsD;;IAEtD,+BAA+B;;CAEzB,CAAC;AAEX,0BAA0B;AAC1B,MAAM,MAAM,aAAa,GACrB,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,OAAO,cAAc,CAAC,CAAC;AAEzD,4EAA4E;AAC5E,eAAO,MAAM,OAAO,EAAG,SAAkB,CAAC;AAE1C,6EAA6E;AAC7E,MAAM,WAAW,iBAAiB;IAC9B,QAAQ,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,OAAO,CAAC;IAC9C,qEAAqE;IACrE,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAC9B,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED,gEAAgE;AAChE,MAAM,MAAM,sBAAsB,GAAG,QAAQ,CACzC,MAAM,CAAC,cAAc,EAAE,iBAAiB,CAAC,CAC5C,CAAC;AAEF,wCAAwC;AACxC,MAAM,WAAW,sBAAsB;IACnC,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,cAAc,EAAE,iBAAiB,CAAC,CAAC,CAAC;IACzE;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,eAAe,CAAC;CACrC;AAED,oDAAoD;AACpD,eAAO,MAAM,eAAe;IACxB,2EAA2E;;IAE3E,uEAAuE;;IAEvE,uEAAuE;;CAEjE,CAAC;AAEX,2BAA2B;AAC3B,MAAM,MAAM,cAAc,GACtB,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,OAAO,eAAe,CAAC,CAAC;AAE3D,6DAA6D;AAC7D,eAAO,MAAM,YAAY;;;;;;CAMf,CAAC;AAEX,wBAAwB;AACxB,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,OAAO,YAAY,CAAC,CAAC;AAE/E,2EAA2E;AAC3E,MAAM,MAAM,UAAU,GAAG,QAAQ,CAAC,MAAM,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;AAE7E,uCAAuC;AACvC,MAAM,WAAW,kBAAkB;IAC/B,oEAAoE;IACpE,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAC;IACjC,yEAAyE;IACzE,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,sBAAsB,EAAE,CAAC;IACpD;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC;IACnC,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IAChC,oEAAoE;IACpE,QAAQ,CAAC,MAAM,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IAC9B,+CAA+C;IAC/C,QAAQ,CAAC,QAAQ,CAAC,EAAE,cAAc,CAAC;CACtC;AAED,sEAAsE;AACtE,MAAM,WAAW,kBAAkB;IAC/B,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC;IAChC,yEAAyE;IACzE,QAAQ,CAAC,MAAM,EAAE,SAAS,sBAAsB,EAAE,CAAC;IACnD,QAAQ,CAAC,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE,IAAI,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,cAAc,CAAC;CACrC;AAED,gEAAgE;AAChE,eAAO,MAAM,WAAW,EAAE,kBAMzB,CAAC;AAEF,0DAA0D;AAC1D,wBAAgB,cAAc,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAOhE"}
@@ -0,0 +1,103 @@
1
+ import { type AuthoredAccessRule } from './access-rule';
2
+ import type { SegmentTypeKey } from './segment-type';
3
+ /**
4
+ * How an entry's access is expressed **before** segmentation, in the scheme
5
+ * almost every CMS reaches for first: two relation fields on the entry, one
6
+ * listing who may read it and one listing who may not.
7
+ *
8
+ * The names are the shape's, not this CMS's — an adopter's fields are called
9
+ * `subscriptions` and `excludedSubscriptions`, or `plans` and `blockedPlans`.
10
+ * What matters is that both sides name the *same kind of thing*, which is what
11
+ * makes them one axis.
12
+ */
13
+ export interface RelationSets {
14
+ /**
15
+ * Segment ids that may read the entry. **Empty means everyone**, which is
16
+ * the convention every instance of this scheme uses — an entry nobody
17
+ * bothered to restrict is an open entry, not a closed one.
18
+ */
19
+ readonly included: readonly string[];
20
+ /** Segment ids that may not read it, whatever `included` says. */
21
+ readonly excluded: readonly string[];
22
+ }
23
+ /**
24
+ * Translates one entry's relation sets into the rule that means the same thing.
25
+ *
26
+ * ## Why this is a function and not a migration script
27
+ *
28
+ * There is no old scheme *in this repository* to migrate from — the scheme
29
+ * lives in the app adopting segmentation, in fields this CMS has never seen,
30
+ * under names only that app knows. A script here would have to invent the input
31
+ * format it claims to read. What is genuinely shared is the **mapping**, and
32
+ * getting it wrong is what produces content that silently opens: so the mapping
33
+ * is the part that ships, with a test, and the adopter's script is a loop that
34
+ * reads their fields, calls this, and posts the result to `/api/access`.
35
+ * `docs/segments-adoption.md` is that loop, written out.
36
+ *
37
+ * ## The three rules of the translation
38
+ *
39
+ * **An empty `included` is not an empty `only`.** This is the whole trap. In
40
+ * the relation scheme an empty include list means *everyone*; in this model an
41
+ * `only` naming nobody admits *nobody*. Translating one to the other literally
42
+ * turns every unrestricted entry in a library dark on the day of the migration
43
+ * — so an empty `included` produces the `all` mode, and the entry stays open.
44
+ *
45
+ * **`excluded` becomes an exclusion, never the complement.** Listing the other
46
+ * three hundred and ninety-seven segments would be storing the complement,
47
+ * which is the one thing the projection invariant forbids: onboarding a new
48
+ * organisation would then have to rewrite every entry in the library.
49
+ *
50
+ * **The result is one group.** The old scheme has no disjunction to preserve —
51
+ * it is a single include list AND-ed with a single exclude list — so a
52
+ * translation that produced several groups would be inventing structure the
53
+ * source never carried.
54
+ */
55
+ export declare function ruleFromRelationSets(typeKey: SegmentTypeKey, sets: RelationSets): AuthoredAccessRule;
56
+ /**
57
+ * Groups entries by the rule they translate to, so an adopter creates a handful
58
+ * of rules rather than one per entry.
59
+ *
60
+ * This is the difference between a usable migration and an unusable one. A
61
+ * library of forty thousand articles carrying the same two subscription tiers
62
+ * has, in practice, a few dozen distinct combinations — and one rule per entry
63
+ * would produce forty thousand rows nobody can read, edit, or reason about,
64
+ * defeating the reuse that made a rule worth having. Entries sharing a
65
+ * combination share a rule and get an assignment each.
66
+ *
67
+ * The signature key is the two **sorted** id lists, because the relation scheme
68
+ * has no order and two entries listing the same segments differently are the
69
+ * same rule. Entries whose sets restrict nobody are returned under
70
+ * {@link RelationSetGrouping.unrestricted} rather than given an empty rule:
71
+ * they need no assignment at all, and the count of them is the first number an
72
+ * adopter should sanity-check against their own.
73
+ */
74
+ export declare function groupByRelationSets<T>(entries: readonly {
75
+ entry: T;
76
+ sets: RelationSets;
77
+ }[]): RelationSetGrouping<T>;
78
+ /** One distinct combination, and the entries carrying it. */
79
+ export interface RelationSetGroup<T> {
80
+ /** The sorted-id signature the grouping keyed on. */
81
+ signature: string;
82
+ /** The normalised sets — deduplicated and sorted. */
83
+ sets: RelationSets;
84
+ /** Every entry with this combination. */
85
+ entries: readonly T[];
86
+ }
87
+ /** What {@link groupByRelationSets} returns. */
88
+ export interface RelationSetGrouping<T> {
89
+ /** One entry per distinct combination — the rules to create. */
90
+ groups: readonly RelationSetGroup<T>[];
91
+ /** Entries whose sets restrict nobody. They need no rule and no assignment. */
92
+ unrestricted: readonly T[];
93
+ }
94
+ /**
95
+ * Whether a translation would fit the model, and what to do when it does not.
96
+ *
97
+ * Called before writing anything. The one bound worth checking up front is the
98
+ * group ceiling — an adopter whose scheme carries a genuine disjunction (two
99
+ * independent axes OR-ed) will hit it, and finding out entry by entry through
100
+ * failed writes is a migration that stops half done.
101
+ */
102
+ export declare function relationSetsFit(count: number): boolean;
103
+ //# sourceMappingURL=adopt-relation-sets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adopt-relation-sets.d.ts","sourceRoot":"","sources":["../../src/lib/adopt-relation-sets.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAExE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAErD;;;;;;;;;GASG;AACH,MAAM,WAAW,YAAY;IACzB;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,kEAAkE;IAClE,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;CACxC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,oBAAoB,CAChC,OAAO,EAAE,cAAc,EACvB,IAAI,EAAE,YAAY,GACnB,kBAAkB,CA8BpB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,EACjC,OAAO,EAAE,SAAS;IAAE,KAAK,EAAE,CAAC,CAAC;IAAC,IAAI,EAAE,YAAY,CAAA;CAAE,EAAE,GACrD,mBAAmB,CAAC,CAAC,CAAC,CA+BxB;AAED,6DAA6D;AAC7D,MAAM,WAAW,gBAAgB,CAAC,CAAC;IAC/B,qDAAqD;IACrD,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,IAAI,EAAE,YAAY,CAAC;IACnB,yCAAyC;IACzC,OAAO,EAAE,SAAS,CAAC,EAAE,CAAC;CACzB;AAED,gDAAgD;AAChD,MAAM,WAAW,mBAAmB,CAAC,CAAC;IAClC,gEAAgE;IAChE,MAAM,EAAE,SAAS,gBAAgB,CAAC,CAAC,CAAC,EAAE,CAAC;IACvC,+EAA+E;IAC/E,YAAY,EAAE,SAAS,CAAC,EAAE,CAAC;CAC9B;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEtD"}
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Which way a segment is pointed at one entry.
3
+ *
4
+ * Two values, and no third. An entry lists the segments somebody made a
5
+ * decision about; a segment nobody mentioned is simply absent, which is not the
6
+ * same as either — see {@link canRead}.
7
+ */
8
+ export declare const ACCESS_MODE: {
9
+ /** This segment may read the entry. */
10
+ readonly Allow: "allow";
11
+ /** This segment may not, whatever any allow says. */
12
+ readonly Deny: "deny";
13
+ };
14
+ /** One value of {@link ACCESS_MODE}. */
15
+ export type AccessMode = (typeof ACCESS_MODE)[keyof typeof ACCESS_MODE];
16
+ /** One entry's decisions — the segment ids on each side. */
17
+ export interface EntryAccess {
18
+ /** Segments that may read it. **Empty means everyone.** */
19
+ readonly allow: readonly string[];
20
+ /** Segments that may not, whatever `allow` says. */
21
+ readonly deny: readonly string[];
22
+ }
23
+ /** An entry nobody has decided anything about. */
24
+ export declare const OPEN_ACCESS: EntryAccess;
25
+ /**
26
+ * Whether a reader may read the entry.
27
+ *
28
+ * Three rules, in this order, and the order is the whole specification:
29
+ *
30
+ * 1. **A deny wins.** A reader in any denied segment is out, however many allow
31
+ * lists they are also in. Put the other way round and "everyone in Europe
32
+ * except this one customer" would be unsayable.
33
+ * 2. **An empty allow list means everyone.** Not nobody. The two look like the
34
+ * same emptiness and are opposite: an entry nobody has restricted is the
35
+ * state every entry starts in, and reading it as a closed door would black
36
+ * out a library the day the feature is switched on.
37
+ * 3. **Otherwise the reader must be in the allow list.** Including the
38
+ * anonymous reader, who is in no segment at all and therefore sees only
39
+ * entries with an empty allow list.
40
+ *
41
+ * That is the entire model. There is no inheritance, no rule object, no
42
+ * ordering between entries, and nothing to resolve — an entry's two lists are
43
+ * the answer, so what an editor sets is exactly what a reader gets.
44
+ */
45
+ export declare function canRead(access: EntryAccess, readerSegmentIds: ReadonlySet<string>): boolean;
46
+ /**
47
+ * Whether an entry's lists restrict anybody.
48
+ *
49
+ * What the editor's badge and the storage layer both key on: an unrestricted
50
+ * entry is stored as **no row at all**, so a reader's query finds nothing and
51
+ * pays an index probe rather than a comparison.
52
+ */
53
+ export declare function isOpen(access: EntryAccess): boolean;
54
+ /**
55
+ * Whether two sets of lists say the same thing.
56
+ *
57
+ * Order-insensitive, because the lists are **sets** everywhere they matter —
58
+ * `canRead` asks about membership, the stored arrays come back in insertion
59
+ * order, and a caller that resent the same audiences in a different order is
60
+ * asking for no change at all.
61
+ *
62
+ * Three callers depend on that reading: the editor's staging clears itself when
63
+ * a toggle returns to what is stored, the entry-write extension skips a write —
64
+ * and therefore a permission check — that would change nothing, and the same
65
+ * extension decides from it whether a locale group is already in step.
66
+ */
67
+ export declare function sameAccess(a: EntryAccess, b: EntryAccess): boolean;
68
+ //# sourceMappingURL=entry-access.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entry-access.d.ts","sourceRoot":"","sources":["../../src/lib/entry-access.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,eAAO,MAAM,WAAW;IACpB,uCAAuC;;IAEvC,qDAAqD;;CAE/C,CAAC;AAEX,wCAAwC;AACxC,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,OAAO,WAAW,CAAC,CAAC;AAExE,4DAA4D;AAC5D,MAAM,WAAW,WAAW;IACxB,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAED,kDAAkD;AAClD,eAAO,MAAM,WAAW,EAAE,WAAqC,CAAC;AAEhE;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,OAAO,CACnB,MAAM,EAAE,WAAW,EACnB,gBAAgB,EAAE,WAAW,CAAC,MAAM,CAAC,GACtC,OAAO,CAMT;AAED;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAEnD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,EAAE,WAAW,GAAG,OAAO,CAKlE"}
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OPEN_ACCESS = exports.ACCESS_MODE = void 0;
4
+ exports.canRead = canRead;
5
+ exports.isOpen = isOpen;
6
+ exports.sameAccess = sameAccess;
7
+ /**
8
+ * Which way a segment is pointed at one entry.
9
+ *
10
+ * Two values, and no third. An entry lists the segments somebody made a
11
+ * decision about; a segment nobody mentioned is simply absent, which is not the
12
+ * same as either — see {@link canRead}.
13
+ */
14
+ exports.ACCESS_MODE = {
15
+ /** This segment may read the entry. */
16
+ Allow: 'allow',
17
+ /** This segment may not, whatever any allow says. */
18
+ Deny: 'deny'
19
+ };
20
+ /** An entry nobody has decided anything about. */
21
+ exports.OPEN_ACCESS = { allow: [], deny: [] };
22
+ /**
23
+ * Whether a reader may read the entry.
24
+ *
25
+ * Three rules, in this order, and the order is the whole specification:
26
+ *
27
+ * 1. **A deny wins.** A reader in any denied segment is out, however many allow
28
+ * lists they are also in. Put the other way round and "everyone in Europe
29
+ * except this one customer" would be unsayable.
30
+ * 2. **An empty allow list means everyone.** Not nobody. The two look like the
31
+ * same emptiness and are opposite: an entry nobody has restricted is the
32
+ * state every entry starts in, and reading it as a closed door would black
33
+ * out a library the day the feature is switched on.
34
+ * 3. **Otherwise the reader must be in the allow list.** Including the
35
+ * anonymous reader, who is in no segment at all and therefore sees only
36
+ * entries with an empty allow list.
37
+ *
38
+ * That is the entire model. There is no inheritance, no rule object, no
39
+ * ordering between entries, and nothing to resolve — an entry's two lists are
40
+ * the answer, so what an editor sets is exactly what a reader gets.
41
+ */
42
+ function canRead(access, readerSegmentIds) {
43
+ for (const id of access.deny) {
44
+ if (readerSegmentIds.has(id))
45
+ return false;
46
+ }
47
+ if (!access.allow.length)
48
+ return true;
49
+ return access.allow.some((id) => readerSegmentIds.has(id));
50
+ }
51
+ /**
52
+ * Whether an entry's lists restrict anybody.
53
+ *
54
+ * What the editor's badge and the storage layer both key on: an unrestricted
55
+ * entry is stored as **no row at all**, so a reader's query finds nothing and
56
+ * pays an index probe rather than a comparison.
57
+ */
58
+ function isOpen(access) {
59
+ return access.allow.length === 0 && access.deny.length === 0;
60
+ }
61
+ /**
62
+ * Whether two sets of lists say the same thing.
63
+ *
64
+ * Order-insensitive, because the lists are **sets** everywhere they matter —
65
+ * `canRead` asks about membership, the stored arrays come back in insertion
66
+ * order, and a caller that resent the same audiences in a different order is
67
+ * asking for no change at all.
68
+ *
69
+ * Three callers depend on that reading: the editor's staging clears itself when
70
+ * a toggle returns to what is stored, the entry-write extension skips a write —
71
+ * and therefore a permission check — that would change nothing, and the same
72
+ * extension decides from it whether a locale group is already in step.
73
+ */
74
+ function sameAccess(a, b) {
75
+ const equal = (left, right) => left.length === right.length &&
76
+ [...left].sort().join() === [...right].sort().join();
77
+ return equal(a.allow, b.allow) && equal(a.deny, b.deny);
78
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The access decision — the single place that answers "may this reader see
3
+ * this?".
4
+ *
5
+ * A domain service in the strict sense: no database, no framework, no clock of
6
+ * its own. That is what lets the same function run in the SQL compiler's tests,
7
+ * in the admin's "who sees this" panel, and on a client rendering a paywall,
8
+ * and give all three the same answer.
9
+ *
10
+ * The order of the checks is not interchangeable:
11
+ *
12
+ * 1. **Exclusions**, absolutely and first. "Everyone except Globex" must not be
13
+ * reopened by a group that happens to match — a denied reader is denied
14
+ * however good their plan is.
15
+ * 2. **The window**, before any group. An embargoed entry is invisible to
16
+ * everyone, so evaluating groups first would only produce a match nobody is
17
+ * allowed to act on.
18
+ * 3. **The groups**, OR-ed. One match is enough, and the rest are not even
19
+ * reported as failures — which is what keeps the explanation short.
20
+ */
21
+ import { type ResolvedAccessRule } from './access-rule';
22
+ import type { SegmentTypeKey } from './segment-type';
23
+ /** Why a reader was refused. */
24
+ export declare const DENIAL_REASON: {
25
+ /** Matched a rule-level exclusion. */
26
+ readonly Excluded: "excluded";
27
+ /** The visibility window is not open. */
28
+ readonly Window: "window";
29
+ /** No condition group matched. */
30
+ readonly NoGroup: "no-group";
31
+ };
32
+ /** @see DENIAL_REASON */
33
+ export type DenialReason = (typeof DENIAL_REASON)[keyof typeof DENIAL_REASON];
34
+ /** The group a refused reader came closest to, and what stopped them. */
35
+ export interface ClosestGroup {
36
+ /** Index into {@link ResolvedAccessRule.groups}. */
37
+ readonly index: number;
38
+ /** Segment type keys whose condition failed, in declaration order. */
39
+ readonly failed: readonly SegmentTypeKey[];
40
+ /** Segment ids that would have satisfied those failing conditions. */
41
+ readonly requires: Readonly<Record<SegmentTypeKey, readonly string[]>>;
42
+ }
43
+ /** The verdict for one reader on one entry. */
44
+ export type AccessDecision = {
45
+ readonly visible: true;
46
+ /**
47
+ * Which group let them through — `-1` when the rule restricts
48
+ * nothing, so "unrestricted" and "matched group 0" stay
49
+ * distinguishable in an explanation.
50
+ */
51
+ readonly matchedGroup: number;
52
+ } | {
53
+ readonly visible: false;
54
+ readonly reason: DenialReason;
55
+ readonly fallback: ResolvedAccessRule['fallback'];
56
+ /** Absent when the refusal was an exclusion or the window. */
57
+ readonly closest?: ClosestGroup;
58
+ };
59
+ /** What {@link evaluate} is asked about. */
60
+ export interface EvaluationInput {
61
+ /** The entry's rule, after inheritance. */
62
+ readonly rule: ResolvedAccessRule;
63
+ /** The reader's segment ids, already resolved from their tags. */
64
+ readonly callerSegmentIds: ReadonlySet<string>;
65
+ /** The instant the decision is made at — passed in, never read here. */
66
+ readonly now: Date;
67
+ }
68
+ /**
69
+ * Decide whether one reader may see one entry.
70
+ *
71
+ * **An empty `groups` means unrestricted**, not "matches nothing". A rule with
72
+ * no conditions is what every entry starts life with, and reading it as a
73
+ * closed door would black out an installation the moment the plugin is
74
+ * enabled. Exclusions and the window still apply on their own, so
75
+ * "everyone except Globex" needs no group at all.
76
+ */
77
+ export declare function evaluate(input: EvaluationInput): AccessDecision;
78
+ //# sourceMappingURL=evaluate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"evaluate.d.ts","sourceRoot":"","sources":["../../src/lib/evaluate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAGH,KAAK,kBAAkB,EAG1B,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAErD,gCAAgC;AAChC,eAAO,MAAM,aAAa;IACtB,sCAAsC;;IAEtC,yCAAyC;;IAEzC,kCAAkC;;CAE5B,CAAC;AAEX,yBAAyB;AACzB,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,OAAO,aAAa,CAAC,CAAC;AAE9E,yEAAyE;AACzE,MAAM,WAAW,YAAY;IACzB,oDAAoD;IACpD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,sEAAsE;IACtE,QAAQ,CAAC,MAAM,EAAE,SAAS,cAAc,EAAE,CAAC;IAC3C,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;CAC1E;AAED,+CAA+C;AAC/C,MAAM,MAAM,cAAc,GACpB;IACI,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CACjC,GACD;IACI,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,QAAQ,CAAC,QAAQ,EAAE,kBAAkB,CAAC,UAAU,CAAC,CAAC;IAClD,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,CAAC,EAAE,YAAY,CAAC;CACnC,CAAC;AAER,4CAA4C;AAC5C,MAAM,WAAW,eAAe;IAC5B,2CAA2C;IAC3C,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAC;IAClC,kEAAkE;IAClE,QAAQ,CAAC,gBAAgB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC/C,wEAAwE;IACxE,QAAQ,CAAC,GAAG,EAAE,IAAI,CAAC;CACtB;AAgGD;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,eAAe,GAAG,cAAc,CAsC/D"}
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Inheritance — turning the chain of levels an entry sits under into the one
3
+ * rule {@link evaluate} reads.
4
+ *
5
+ * The chain runs installation → workspace → type → slice → entry, and the rule
6
+ * for merging it is the answer to a question the plan leaves open on purpose:
7
+ * a lower level **adds** its groups to what it inherited rather than replacing
8
+ * them. Replacement as the default loses a type's restriction the moment
9
+ * somebody edits one entry — the failure is silent and the content is already
10
+ * public by the time anyone notices. Detaching stays possible, but it has to be
11
+ * asked for ({@link AuthoredAccessRule.detachInherited}).
12
+ *
13
+ * Exclusions never merge downward the other way: they are unioned across the
14
+ * whole chain and a lower level cannot lift one. That is the same "deny wins"
15
+ * rule {@link evaluate} enforces within a single rule, applied across levels.
16
+ */
17
+ import { type AccessLevelName, type AuthoredAccessRule, type ResolvedAccessRule } from './access-rule';
18
+ /** One link in the chain: where the rule was authored, and what it says. */
19
+ export interface AccessLevel {
20
+ /** Which level this is. Used to label an inherited group in the editor. */
21
+ readonly name: AccessLevelName;
22
+ /** The rule authored there, or `null`/absent when the level says nothing. */
23
+ readonly rule?: AuthoredAccessRule | null;
24
+ }
25
+ /** A resolved rule plus what it was assembled from. */
26
+ export interface ResolvedAccess {
27
+ /** What {@link evaluate} takes. */
28
+ readonly rule: ResolvedAccessRule;
29
+ /** Levels that contributed anything, outermost first. */
30
+ readonly contributors: readonly AccessLevelName[];
31
+ /** The level whose `detachInherited` dropped the chain above it, if any. */
32
+ readonly detachedAt?: AccessLevelName;
33
+ }
34
+ /**
35
+ * Collapse the chain into a single rule.
36
+ *
37
+ * Levels are read outermost-first and each may add groups, add exclusions, set
38
+ * the window, set the fallback, or detach. The **nearest** level that states a
39
+ * window or a fallback wins, because those are single-valued — unlike groups,
40
+ * which accumulate.
41
+ */
42
+ export declare function resolveAccess(levels: readonly AccessLevel[]): ResolvedAccess;
43
+ //# sourceMappingURL=inheritance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inheritance.d.ts","sourceRoot":"","sources":["../../src/lib/inheritance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAKH,KAAK,eAAe,EACpB,KAAK,kBAAkB,EAGvB,KAAK,kBAAkB,EAG1B,MAAM,eAAe,CAAC;AAGvB,4EAA4E;AAC5E,MAAM,WAAW,WAAW;IACxB,2EAA2E;IAC3E,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CAC7C;AAED,uDAAuD;AACvD,MAAM,WAAW,cAAc;IAC3B,mCAAmC;IACnC,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAC;IAClC,yDAAyD;IACzD,QAAQ,CAAC,YAAY,EAAE,SAAS,eAAe,EAAE,CAAC;IAClD,4EAA4E;IAC5E,QAAQ,CAAC,UAAU,CAAC,EAAE,eAAe,CAAC;CACzC;AA6CD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,GAAG,cAAc,CA0D5E"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The ceilings segmentation runs under.
3
+ *
4
+ * Both are cost limits with a visible failure mode, not arbitrary round
5
+ * numbers, and both are stated here so the server's validation and the admin's
6
+ * "2 of 8" counter read the same constant.
7
+ */
8
+ /**
9
+ * Condition groups per rule.
10
+ *
11
+ * Each group is one row of the entry's projection, so a group added at type
12
+ * level multiplies the projection for every entry of that type. Eight is the
13
+ * point past which "add another OR" stops being a cheap edit; the number is a
14
+ * starting position to be revisited against a real write benchmark, not a
15
+ * measured optimum.
16
+ */
17
+ export declare const MAX_CONDITION_GROUPS = 8;
18
+ /**
19
+ * Segment types active at once.
20
+ *
21
+ * Bounded by the projection's slot columns — a type claims one `allow`/`deny`
22
+ * pair, and the pairs are created by migration rather than by runtime DDL, so
23
+ * the schema stays reproducible from a checkout. Raising it is one migration
24
+ * that adds the next batch.
25
+ */
26
+ export declare const MAX_SEGMENT_TYPES = 8;
27
+ //# sourceMappingURL=limits.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"limits.d.ts","sourceRoot":"","sources":["../../src/lib/limits.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,IAAI,CAAC"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Where a reader's tags come from.
3
+ *
4
+ * The one thing this package cannot know: a deployment's readers are its own —
5
+ * a JWT claim, a header the CDN sets, a lookup against a billing system. So the
6
+ * host implements this, and everything above it deals in tags.
7
+ *
8
+ * **It fails closed by returning nothing, never by throwing.** An unreachable
9
+ * source produces the anonymous reader, who still sees every unrestricted
10
+ * entry; taking the site down over content most of its readers can see anyway
11
+ * is the wrong trade.
12
+ */
13
+ export interface SegmentResolver<TRequest = unknown> {
14
+ /** The tags this reader carries. Empty is the anonymous reader. */
15
+ resolve(request: TRequest): Promise<readonly string[]>;
16
+ }
17
+ /** A resolver that always answers with the same tags — for tests and demos. */
18
+ export declare function staticSegmentResolver<TRequest>(tags: readonly string[]): SegmentResolver<TRequest>;
19
+ /**
20
+ * The resolver a deployment gets when it configures none: every reader is
21
+ * anonymous, so unrestricted content serves and restricted content does not.
22
+ * That is the honest default — the alternative, treating an unknown reader as
23
+ * unconstrained, turns every gap in configuration into an open door.
24
+ */
25
+ export declare function anonymousSegmentResolver<TRequest>(): SegmentResolver<TRequest>;
26
+ //# sourceMappingURL=segment-resolver.port.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"segment-resolver.port.d.ts","sourceRoot":"","sources":["../../src/lib/segment-resolver.port.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,eAAe,CAAC,QAAQ,GAAG,OAAO;IAC/C,mEAAmE;IACnE,OAAO,CAAC,OAAO,EAAE,QAAQ,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAAC;CAC1D;AAED,+EAA+E;AAC/E,wBAAgB,qBAAqB,CAAC,QAAQ,EAC1C,IAAI,EAAE,SAAS,MAAM,EAAE,GACxB,eAAe,CAAC,QAAQ,CAAC,CAE3B;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACpC,QAAQ,KACP,eAAe,CAAC,QAAQ,CAAC,CAE7B"}
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.staticSegmentResolver = staticSegmentResolver;
4
+ exports.anonymousSegmentResolver = anonymousSegmentResolver;
5
+ /** A resolver that always answers with the same tags — for tests and demos. */
6
+ function staticSegmentResolver(tags) {
7
+ return { resolve: async () => tags };
8
+ }
9
+ /**
10
+ * The resolver a deployment gets when it configures none: every reader is
11
+ * anonymous, so unrestricted content serves and restricted content does not.
12
+ * That is the honest default — the alternative, treating an unknown reader as
13
+ * unconstrained, turns every gap in configuration into an open door.
14
+ */
15
+ function anonymousSegmentResolver() {
16
+ return { resolve: async () => [] };
17
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * A **segment type** — one axis of the access decision, and the unit of AND
3
+ * logic. "Organisation", "Plan", "Region" and "Role" are all segment types; the
4
+ * kernel knows none of them by name, only that each owns a tag namespace and
5
+ * that types are AND-ed with one another.
6
+ */
7
+ /** A reader's raw entitlement tag, namespaced by its type key: `org:acme`. */
8
+ export type SegmentTag = string;
9
+ /** The namespace half of a tag — and the key of the type that owns it. */
10
+ export type SegmentTypeKey = string;
11
+ /**
12
+ * How many segments a type is expected to hold, and therefore how the admin
13
+ * renders it: `low` earns matrix columns, `high` gets a searchable picker. A
14
+ * hint for the UI, never consulted by {@link evaluate}.
15
+ */
16
+ export declare const SEGMENT_CARDINALITY: {
17
+ readonly Low: "low";
18
+ readonly High: "high";
19
+ };
20
+ /** @see SEGMENT_CARDINALITY */
21
+ export type SegmentCardinality = (typeof SEGMENT_CARDINALITY)[keyof typeof SEGMENT_CARDINALITY];
22
+ /**
23
+ * Where a type's segments come from. The kernel does not read any of these —
24
+ * it is the server package that populates segments from the named source — but
25
+ * the vocabulary lives here so both runtimes name the four the same way.
26
+ */
27
+ export declare const SEGMENT_SOURCE_KIND: {
28
+ /** Maintained by hand in the admin (plans, tiers). */
29
+ readonly Manual: "manual";
30
+ /** A fixed list declared in configuration (regions). */
31
+ readonly Static: "static";
32
+ /** Mirrors a content collection — one segment per record (organisations). */
33
+ readonly ContentType: "contentType";
34
+ /** Mirrors an external directory, read through the resolver's adapter. */
35
+ readonly External: "external";
36
+ };
37
+ /** @see SEGMENT_SOURCE_KIND */
38
+ export type SegmentSourceKind = (typeof SEGMENT_SOURCE_KIND)[keyof typeof SEGMENT_SOURCE_KIND];
39
+ /**
40
+ * A type's lifecycle. `draining` is the state that makes slot reuse safe: the
41
+ * type has left the predicate and the UI, but its slot columns still hold the
42
+ * old ids and must be zeroed before another type may claim it. Skipping it is
43
+ * how a new type would silently inherit a stranger's segments.
44
+ */
45
+ export declare const SEGMENT_TYPE_STATE: {
46
+ readonly Active: "active";
47
+ readonly Draining: "draining";
48
+ readonly Free: "free";
49
+ };
50
+ /** @see SEGMENT_TYPE_STATE */
51
+ export type SegmentTypeState = (typeof SEGMENT_TYPE_STATE)[keyof typeof SEGMENT_TYPE_STATE];
52
+ /** Who owns a type's definition — and therefore whether the admin may edit it. */
53
+ export declare const SEGMENT_TYPE_MANAGED_BY: {
54
+ /** Declared in `ortha.config.ts`; read-only in the admin. */
55
+ readonly Config: "config";
56
+ /** Created in the admin; editable there. */
57
+ readonly Ui: "ui";
58
+ };
59
+ /** @see SEGMENT_TYPE_MANAGED_BY */
60
+ export type SegmentTypeManagedBy = (typeof SEGMENT_TYPE_MANAGED_BY)[keyof typeof SEGMENT_TYPE_MANAGED_BY];
61
+ /** One declared axis of the access decision. */
62
+ export interface SegmentType {
63
+ /** Stable id. */
64
+ readonly id: string;
65
+ /** Tag namespace this type owns, e.g. `org` for `org:acme`. */
66
+ readonly key: SegmentTypeKey;
67
+ /** Human-readable name, shown in the editor. */
68
+ readonly label: string;
69
+ /** Rendering hint. @see SEGMENT_CARDINALITY */
70
+ readonly cardinality: SegmentCardinality;
71
+ /** Which projection slot pair this type reads and writes. */
72
+ readonly slot: number;
73
+ /** @see SEGMENT_TYPE_STATE */
74
+ readonly state: SegmentTypeState;
75
+ /** @see SEGMENT_TYPE_MANAGED_BY */
76
+ readonly managedBy: SegmentTypeManagedBy;
77
+ }
78
+ /**
79
+ * The namespace a tag belongs to — everything before the first `:`.
80
+ *
81
+ * A tag with no separator has **no** namespace rather than being its own: a
82
+ * bare `pro` must not be swept up by a mask over some type that happens to be
83
+ * called `pro`, and returning `undefined` is what keeps
84
+ * {@link segmentMatchesTag} from guessing.
85
+ */
86
+ export declare function tagNamespace(tag: SegmentTag): SegmentTypeKey | undefined;
87
+ //# sourceMappingURL=segment-type.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"segment-type.d.ts","sourceRoot":"","sources":["../../src/lib/segment-type.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,8EAA8E;AAC9E,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC;AAEhC,0EAA0E;AAC1E,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC;AAEpC;;;;GAIG;AACH,eAAO,MAAM,mBAAmB;;;CAGtB,CAAC;AAEX,+BAA+B;AAC/B,MAAM,MAAM,kBAAkB,GAC1B,CAAC,OAAO,mBAAmB,CAAC,CAAC,MAAM,OAAO,mBAAmB,CAAC,CAAC;AAEnE;;;;GAIG;AACH,eAAO,MAAM,mBAAmB;IAC5B,sDAAsD;;IAEtD,wDAAwD;;IAExD,6EAA6E;;IAE7E,0EAA0E;;CAEpE,CAAC;AAEX,+BAA+B;AAC/B,MAAM,MAAM,iBAAiB,GACzB,CAAC,OAAO,mBAAmB,CAAC,CAAC,MAAM,OAAO,mBAAmB,CAAC,CAAC;AAEnE;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB;;;;CAIrB,CAAC;AAEX,8BAA8B;AAC9B,MAAM,MAAM,gBAAgB,GACxB,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,OAAO,kBAAkB,CAAC,CAAC;AAEjE,kFAAkF;AAClF,eAAO,MAAM,uBAAuB;IAChC,6DAA6D;;IAE7D,4CAA4C;;CAEtC,CAAC;AAEX,mCAAmC;AACnC,MAAM,MAAM,oBAAoB,GAC5B,CAAC,OAAO,uBAAuB,CAAC,CAAC,MAAM,OAAO,uBAAuB,CAAC,CAAC;AAE3E,gDAAgD;AAChD,MAAM,WAAW,WAAW;IACxB,iBAAiB;IACjB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,+DAA+D;IAC/D,QAAQ,CAAC,GAAG,EAAE,cAAc,CAAC;IAC7B,gDAAgD;IAChD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,+CAA+C;IAC/C,QAAQ,CAAC,WAAW,EAAE,kBAAkB,CAAC;IACzC,6DAA6D;IAC7D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,8BAA8B;IAC9B,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAC;IACjC,mCAAmC;IACnC,QAAQ,CAAC,SAAS,EAAE,oBAAoB,CAAC;CAC5C;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,cAAc,GAAG,SAAS,CAGxE"}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * One audience — a named set of reader tags.
3
+ *
4
+ * The **tags** are the indirection that makes the whole thing worth having. A
5
+ * reader arrives carrying identifiers your application already knows (`acme`,
6
+ * `plan-pro`, an organisation id); a segment says which of those it answers to.
7
+ * So when an identifier changes upstream, one segment row is edited and every
8
+ * entry that named the segment keeps working — nothing outside this file ever
9
+ * compares a raw tag.
10
+ */
11
+ export interface Segment {
12
+ /** Stable id — what an entry's allow/deny list holds. */
13
+ readonly id: string;
14
+ /** Url-safe key, unique in the installation. */
15
+ readonly key: string;
16
+ /** Human-readable name, shown in the entry editor. */
17
+ readonly label: string;
18
+ /**
19
+ * Reader tags this segment answers to — **any one** is enough. Defaults to
20
+ * the key, which is what an installation that never renames anything wants.
21
+ */
22
+ readonly tags: readonly string[];
23
+ /**
24
+ * The workspaces this audience is offered in. **Empty means every one.**
25
+ *
26
+ * The same reading as an entry's empty allow list, and it is deliberate
27
+ * rather than convenient: it is the state every segment starts in, and
28
+ * taking emptiness for "nowhere" would make an audience nobody had scoped
29
+ * yet disappear from every editor.
30
+ *
31
+ * It narrows **where the audience can be chosen**, never who it lets in. A
32
+ * decision already made on an entry stays as its editor left it even if the
33
+ * segment is later scoped away from that workspace — see `isOfferedIn`.
34
+ */
35
+ readonly workspaceIds?: readonly string[];
36
+ }
37
+ /**
38
+ * Whether an audience may be chosen on content in this workspace.
39
+ *
40
+ * Not a reader rule. `canRead` never consults it, and it must not: a stored
41
+ * decision means what its editor meant, and re-deciding it from a screen about
42
+ * where an audience is *offered* would change who can read published content
43
+ * with nothing on either screen to say so. This answers the editor's question —
44
+ * "may I pick this here?" — and nothing else.
45
+ */
46
+ export declare function isOfferedIn(segment: Segment, workspaceId: string | undefined): boolean;
47
+ /**
48
+ * The segments a reader's tags resolve to.
49
+ *
50
+ * An exact, case-sensitive match on any tag. There is deliberately no pattern,
51
+ * no prefix and no namespace: those exist to express "any of this kind", which
52
+ * is a question this model answers by not asking it — an entry lists the
53
+ * segments that may read it, and "any" is the empty list.
54
+ */
55
+ export declare function segmentIdsForTags(segments: readonly Segment[], tags: readonly string[]): Set<string>;
56
+ //# sourceMappingURL=segment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"segment.d.ts","sourceRoot":"","sources":["../../src/lib/segment.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,WAAW,OAAO;IACpB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,gDAAgD;IAChD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,sDAAsD;IACtD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7C;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CACvB,OAAO,EAAE,OAAO,EAChB,WAAW,EAAE,MAAM,GAAG,SAAS,GAChC,OAAO,CAKT;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,SAAS,OAAO,EAAE,EAC5B,IAAI,EAAE,SAAS,MAAM,EAAE,GACxB,GAAG,CAAC,MAAM,CAAC,CAUb"}
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isOfferedIn = isOfferedIn;
4
+ exports.segmentIdsForTags = segmentIdsForTags;
5
+ /**
6
+ * Whether an audience may be chosen on content in this workspace.
7
+ *
8
+ * Not a reader rule. `canRead` never consults it, and it must not: a stored
9
+ * decision means what its editor meant, and re-deciding it from a screen about
10
+ * where an audience is *offered* would change who can read published content
11
+ * with nothing on either screen to say so. This answers the editor's question —
12
+ * "may I pick this here?" — and nothing else.
13
+ */
14
+ function isOfferedIn(segment, workspaceId) {
15
+ if (!segment.workspaceIds?.length)
16
+ return true;
17
+ return workspaceId !== undefined
18
+ ? segment.workspaceIds.includes(workspaceId)
19
+ : false;
20
+ }
21
+ /**
22
+ * The segments a reader's tags resolve to.
23
+ *
24
+ * An exact, case-sensitive match on any tag. There is deliberately no pattern,
25
+ * no prefix and no namespace: those exist to express "any of this kind", which
26
+ * is a question this model answers by not asking it — an entry lists the
27
+ * segments that may read it, and "any" is the empty list.
28
+ */
29
+ function segmentIdsForTags(segments, tags) {
30
+ if (!tags.length)
31
+ return new Set();
32
+ const carried = new Set(tags);
33
+ const ids = new Set();
34
+ for (const segment of segments) {
35
+ if (segment.tags.some((tag) => carried.has(tag))) {
36
+ ids.add(segment.id);
37
+ }
38
+ }
39
+ return ids;
40
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * What a segment's fields may hold — the rules, in one place.
3
+ *
4
+ * The admin's dialog and the server's DTO both read from here rather than each
5
+ * spelling out a pattern of its own. Two copies of a validation rule is two
6
+ * copies to drift, and the way it shows up is the worst one available: a form
7
+ * that accepts what the API then refuses, with the refusal arriving as a 400
8
+ * carrying a message written for a different audience.
9
+ */
10
+ /**
11
+ * A segment key: lowercase, url-safe, starting with a letter or digit.
12
+ *
13
+ * It is also the **default reader tag**, which is why it is this narrow. A key
14
+ * with a space or a capital in it would become a tag nobody's resolver produces,
15
+ * and the segment would silently match nobody.
16
+ */
17
+ export declare const SEGMENT_KEY_PATTERN: RegExp;
18
+ /** Longest accepted key. */
19
+ export declare const SEGMENT_KEY_MAX = 120;
20
+ /** Longest accepted label. */
21
+ export declare const SEGMENT_LABEL_MAX = 200;
22
+ /** Longest accepted single reader tag. */
23
+ export declare const SEGMENT_TAG_MAX = 200;
24
+ /** Most reader tags one segment may answer to. */
25
+ export declare const SEGMENT_TAGS_MAX = 20;
26
+ /** Why one field was refused. Stable keys — the admin renders its own copy. */
27
+ export declare const SEGMENT_ISSUE: {
28
+ readonly Required: "required";
29
+ readonly TooLong: "too-long";
30
+ readonly Malformed: "malformed";
31
+ readonly TooMany: "too-many";
32
+ readonly Duplicate: "duplicate";
33
+ };
34
+ /** One value of {@link SEGMENT_ISSUE}. */
35
+ export type SegmentIssue = (typeof SEGMENT_ISSUE)[keyof typeof SEGMENT_ISSUE];
36
+ /** What was wrong, per field. A field with nothing wrong is absent. */
37
+ export type SegmentIssues = {
38
+ key?: SegmentIssue;
39
+ label?: SegmentIssue;
40
+ tags?: SegmentIssue;
41
+ };
42
+ /** What is being checked. */
43
+ export interface SegmentDraft {
44
+ /** Absent when editing — the key is immutable. */
45
+ readonly key?: string;
46
+ readonly label: string;
47
+ readonly tags: readonly string[];
48
+ }
49
+ /**
50
+ * Checks one draft, returning an issue per bad field.
51
+ *
52
+ * Returns a **map, not a first error**: a form shows every field's problem at
53
+ * once, and reporting them one at a time turns fixing three fields into three
54
+ * round trips through the same button.
55
+ *
56
+ * `existingKeys` catches the collision the server answers 409 for. Checking it
57
+ * here is not a second authority — the server still decides, and still wins a
58
+ * race between two people creating the same key — it just means the common case
59
+ * is answered inline instead of as a failed submit.
60
+ */
61
+ export declare function validateSegment(draft: SegmentDraft, existingKeys?: readonly string[]): SegmentIssues;
62
+ /** Whether a draft has nothing wrong with it. */
63
+ export declare function isValidSegment(issues: SegmentIssues): boolean;
64
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../../src/lib/validation.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,QAA2B,CAAC;AAE5D,4BAA4B;AAC5B,eAAO,MAAM,eAAe,MAAM,CAAC;AACnC,8BAA8B;AAC9B,eAAO,MAAM,iBAAiB,MAAM,CAAC;AACrC,0CAA0C;AAC1C,eAAO,MAAM,eAAe,MAAM,CAAC;AACnC,kDAAkD;AAClD,eAAO,MAAM,gBAAgB,KAAK,CAAC;AAEnC,+EAA+E;AAC/E,eAAO,MAAM,aAAa;;;;;;CAMhB,CAAC;AAEX,0CAA0C;AAC1C,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,OAAO,aAAa,CAAC,CAAC;AAE9E,uEAAuE;AACvE,MAAM,MAAM,aAAa,GAAG;IACxB,GAAG,CAAC,EAAE,YAAY,CAAC;IACnB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,IAAI,CAAC,EAAE,YAAY,CAAC;CACvB,CAAC;AAEF,6BAA6B;AAC7B,MAAM,WAAW,YAAY;IACzB,kDAAkD;IAClD,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAC3B,KAAK,EAAE,YAAY,EACnB,YAAY,GAAE,SAAS,MAAM,EAAO,GACrC,aAAa,CAoCf;AAED,iDAAiD;AACjD,wBAAgB,cAAc,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAE7D"}
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ /**
3
+ * What a segment's fields may hold — the rules, in one place.
4
+ *
5
+ * The admin's dialog and the server's DTO both read from here rather than each
6
+ * spelling out a pattern of its own. Two copies of a validation rule is two
7
+ * copies to drift, and the way it shows up is the worst one available: a form
8
+ * that accepts what the API then refuses, with the refusal arriving as a 400
9
+ * carrying a message written for a different audience.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.SEGMENT_ISSUE = exports.SEGMENT_TAGS_MAX = exports.SEGMENT_TAG_MAX = exports.SEGMENT_LABEL_MAX = exports.SEGMENT_KEY_MAX = exports.SEGMENT_KEY_PATTERN = void 0;
13
+ exports.validateSegment = validateSegment;
14
+ exports.isValidSegment = isValidSegment;
15
+ /**
16
+ * A segment key: lowercase, url-safe, starting with a letter or digit.
17
+ *
18
+ * It is also the **default reader tag**, which is why it is this narrow. A key
19
+ * with a space or a capital in it would become a tag nobody's resolver produces,
20
+ * and the segment would silently match nobody.
21
+ */
22
+ exports.SEGMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
23
+ /** Longest accepted key. */
24
+ exports.SEGMENT_KEY_MAX = 120;
25
+ /** Longest accepted label. */
26
+ exports.SEGMENT_LABEL_MAX = 200;
27
+ /** Longest accepted single reader tag. */
28
+ exports.SEGMENT_TAG_MAX = 200;
29
+ /** Most reader tags one segment may answer to. */
30
+ exports.SEGMENT_TAGS_MAX = 20;
31
+ /** Why one field was refused. Stable keys — the admin renders its own copy. */
32
+ exports.SEGMENT_ISSUE = {
33
+ Required: 'required',
34
+ TooLong: 'too-long',
35
+ Malformed: 'malformed',
36
+ TooMany: 'too-many',
37
+ Duplicate: 'duplicate'
38
+ };
39
+ /**
40
+ * Checks one draft, returning an issue per bad field.
41
+ *
42
+ * Returns a **map, not a first error**: a form shows every field's problem at
43
+ * once, and reporting them one at a time turns fixing three fields into three
44
+ * round trips through the same button.
45
+ *
46
+ * `existingKeys` catches the collision the server answers 409 for. Checking it
47
+ * here is not a second authority — the server still decides, and still wins a
48
+ * race between two people creating the same key — it just means the common case
49
+ * is answered inline instead of as a failed submit.
50
+ */
51
+ function validateSegment(draft, existingKeys = []) {
52
+ const issues = {};
53
+ const label = draft.label.trim();
54
+ if (!label) {
55
+ issues.label = exports.SEGMENT_ISSUE.Required;
56
+ }
57
+ else if (label.length > exports.SEGMENT_LABEL_MAX) {
58
+ issues.label = exports.SEGMENT_ISSUE.TooLong;
59
+ }
60
+ if (draft.key !== undefined) {
61
+ const key = draft.key.trim();
62
+ if (!key) {
63
+ issues.key = exports.SEGMENT_ISSUE.Required;
64
+ }
65
+ else if (key.length > exports.SEGMENT_KEY_MAX) {
66
+ issues.key = exports.SEGMENT_ISSUE.TooLong;
67
+ }
68
+ else if (!exports.SEGMENT_KEY_PATTERN.test(key)) {
69
+ issues.key = exports.SEGMENT_ISSUE.Malformed;
70
+ }
71
+ else if (existingKeys.includes(key)) {
72
+ issues.key = exports.SEGMENT_ISSUE.Duplicate;
73
+ }
74
+ }
75
+ // Blanks are dropped rather than refused — they are what a trailing newline
76
+ // in a pasted list produces, and refusing a paste over its last character
77
+ // is an argument with the user about something they cannot see.
78
+ const tags = draft.tags.map((tag) => tag.trim()).filter(Boolean);
79
+ if (tags.length > exports.SEGMENT_TAGS_MAX) {
80
+ issues.tags = exports.SEGMENT_ISSUE.TooMany;
81
+ }
82
+ else if (tags.some((tag) => tag.length > exports.SEGMENT_TAG_MAX)) {
83
+ issues.tags = exports.SEGMENT_ISSUE.TooLong;
84
+ }
85
+ else if (new Set(tags).size !== tags.length) {
86
+ issues.tags = exports.SEGMENT_ISSUE.Duplicate;
87
+ }
88
+ return issues;
89
+ }
90
+ /** Whether a draft has nothing wrong with it. */
91
+ function isValidSegment(issues) {
92
+ return Object.keys(issues).length === 0;
93
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@orthacms/segments-domain",
3
+ "version": "0.0.0-reserve.0",
4
+ "description": "@orthacms/segments-domain — part of Ortha CMS.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/segments/domain",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ortha-source/ortha-cms.git",
10
+ "directory": "packages/segments/domain"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/ortha-source/ortha-cms/issues"
14
+ },
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "dependencies": {
28
+ "tslib": "^2.3.0"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ }
33
+ }