@authorbot/domain 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ import { z } from "zod";
2
+ import { uuidv7Schema, } from "@authorbot/schemas";
3
+ import { canTransitionAnnotation } from "./annotation-state.js";
4
+ import { ALLOWED, denied } from "./decision.js";
5
+ import { transitionWorkItem } from "./work-item-state.js";
6
+ /**
7
+ * Maintainer overrides (Phase 3 contract section 4; design section 11.2).
8
+ * All four are maintainer-only, require a recorded `reason`, and are recorded
9
+ * as decisions with `override_reason`. Force-create bypasses the rule but
10
+ * respects the same uniqueness key `(source_annotation_id, action_type,
11
+ * rule_version)` with `rule_version: 0`.
12
+ */
13
+ /** Minimum meaningful override reason length (after trimming). */
14
+ export const MIN_OVERRIDE_REASON_LENGTH = 3;
15
+ /** Bound chosen for storage sanity (not contract-pinned). */
16
+ export const MAX_OVERRIDE_REASON_LENGTH = 2000;
17
+ export const overrideReasonSchema = z
18
+ .string()
19
+ .transform((value) => value.trim())
20
+ .refine((value) => value.length >= MIN_OVERRIDE_REASON_LENGTH, `reason must be at least ${MIN_OVERRIDE_REASON_LENGTH} characters`)
21
+ .refine((value) => value.length <= MAX_OVERRIDE_REASON_LENGTH, `reason must be at most ${MAX_OVERRIDE_REASON_LENGTH} characters`);
22
+ /** `rule_version` recorded by force-created decisions (contract section 4). */
23
+ export const FORCE_CREATE_RULE_VERSION = 0;
24
+ /** Override 1: reject an open suggestion. */
25
+ export const rejectSuggestionCommandSchema = z.strictObject({
26
+ annotationId: uuidv7Schema,
27
+ reason: overrideReasonSchema,
28
+ });
29
+ /** Override 2: cancel a `ready` work item. */
30
+ export const cancelWorkItemCommandSchema = z.strictObject({
31
+ workItemId: uuidv7Schema,
32
+ reason: overrideReasonSchema,
33
+ });
34
+ /** Override 3: reopen a rejected suggestion. */
35
+ export const reopenSuggestionCommandSchema = z.strictObject({
36
+ annotationId: uuidv7Schema,
37
+ reason: overrideReasonSchema,
38
+ });
39
+ /**
40
+ * Override 4: force-create a work item bypassing the rule. `work_type` is not
41
+ * part of the command — it resolves from the annotation scope exactly as
42
+ * rule-created items do (Phase 3 contract section 3).
43
+ */
44
+ export const forceCreateWorkItemCommandSchema = z.strictObject({
45
+ annotationId: uuidv7Schema,
46
+ reason: overrideReasonSchema,
47
+ });
48
+ function requireMaintainer(actorRole) {
49
+ if (actorRole !== "maintainer") {
50
+ return denied("not-maintainer", "only a maintainer may perform overrides");
51
+ }
52
+ return ALLOWED;
53
+ }
54
+ function requireSuggestion(kind) {
55
+ if (kind !== "suggestion") {
56
+ return denied("not-a-suggestion", "this override applies to suggestions only");
57
+ }
58
+ return ALLOWED;
59
+ }
60
+ /** Reject an open suggestion (`open -> rejected`). */
61
+ export function authorizeRejectSuggestion(input) {
62
+ const maintainer = requireMaintainer(input.actorRole);
63
+ if (!maintainer.allowed)
64
+ return maintainer;
65
+ const suggestion = requireSuggestion(input.annotationKind);
66
+ if (!suggestion.allowed)
67
+ return suggestion;
68
+ if (!canTransitionAnnotation(input.annotationStatus, "rejected")) {
69
+ return denied("illegal-transition", `a suggestion with status "${input.annotationStatus}" cannot be rejected (only "open")`);
70
+ }
71
+ return ALLOWED;
72
+ }
73
+ /** Reopen a rejected suggestion (`rejected -> open`). */
74
+ export function authorizeReopenSuggestion(input) {
75
+ const maintainer = requireMaintainer(input.actorRole);
76
+ if (!maintainer.allowed)
77
+ return maintainer;
78
+ const suggestion = requireSuggestion(input.annotationKind);
79
+ if (!suggestion.allowed)
80
+ return suggestion;
81
+ if (!canTransitionAnnotation(input.annotationStatus, "open")) {
82
+ return denied("illegal-transition", `a suggestion with status "${input.annotationStatus}" cannot be reopened (only "rejected")`);
83
+ }
84
+ return ALLOWED;
85
+ }
86
+ /** Cancel a `ready` work item (contract section 4: cancel before integration). */
87
+ export function authorizeCancelWorkItem(input) {
88
+ const maintainer = requireMaintainer(input.actorRole);
89
+ if (!maintainer.allowed)
90
+ return maintainer;
91
+ return transitionWorkItem(input.workItemStatus, "cancelled");
92
+ }
93
+ /**
94
+ * Force-create a work item for an open suggestion, bypassing the rule. The
95
+ * annotation must still be able to make the `open -> work_item_created`
96
+ * transition; uniqueness (one item per annotation/action/rule-version) is the
97
+ * DB constraint's job.
98
+ */
99
+ export function authorizeForceCreateWorkItem(input) {
100
+ const maintainer = requireMaintainer(input.actorRole);
101
+ if (!maintainer.allowed)
102
+ return maintainer;
103
+ const suggestion = requireSuggestion(input.annotationKind);
104
+ if (!suggestion.allowed)
105
+ return suggestion;
106
+ if (!canTransitionAnnotation(input.annotationStatus, "work_item_created")) {
107
+ return denied("illegal-transition", `a suggestion with status "${input.annotationStatus}" cannot receive a work item (only "open")`);
108
+ }
109
+ return ALLOWED;
110
+ }
111
+ //# sourceMappingURL=overrides.js.map
@@ -0,0 +1,42 @@
1
+ import { z } from "zod";
2
+ import { type Decision } from "./decision.js";
3
+ /**
4
+ * Known scopes and role -> scope-bundle mapping (Phase 2 contract section 3,
5
+ * design section 19.3). `votes:write` (design section 19.2) is deferred with
6
+ * voting to Phase 3 and is intentionally not a known scope in this phase.
7
+ */
8
+ export declare const SCOPES: readonly ["chapters:read", "annotations:read", "annotations:write", "work:read", "work:claim", "submissions:write", "tokens:manage", "members:manage"];
9
+ export type Scope = (typeof SCOPES)[number];
10
+ export declare const scopeSchema: z.ZodEnum<{
11
+ "chapters:read": "chapters:read";
12
+ "annotations:read": "annotations:read";
13
+ "annotations:write": "annotations:write";
14
+ "work:read": "work:read";
15
+ "work:claim": "work:claim";
16
+ "submissions:write": "submissions:write";
17
+ "tokens:manage": "tokens:manage";
18
+ "members:manage": "members:manage";
19
+ }>;
20
+ export declare const ROLES: readonly ["reader", "contributor", "editor", "maintainer"];
21
+ export type Role = (typeof ROLES)[number];
22
+ export declare const roleSchema: z.ZodEnum<{
23
+ reader: "reader";
24
+ contributor: "contributor";
25
+ editor: "editor";
26
+ maintainer: "maintainer";
27
+ }>;
28
+ /** Role -> scope bundle (contract section 3). Bundles are cumulative. */
29
+ export declare const ROLE_SCOPES: Readonly<Record<Role, readonly Scope[]>>;
30
+ /** The scope bundle a membership role grants (human sessions use this directly). */
31
+ export declare function roleScopes(role: Role): readonly Scope[];
32
+ /**
33
+ * An agent's effective scopes = token.scopes ∩ its membership role bundle
34
+ * (contract section 3). Result is deduplicated and in canonical SCOPES order.
35
+ */
36
+ export declare function effectiveScopes(tokenScopes: readonly Scope[], role: Role): Scope[];
37
+ export type ScopeDenialReason = "missing-scope";
38
+ /** Require a single scope among the actor's effective scopes. */
39
+ export declare function requireScope(actorScopes: readonly Scope[], required: Scope): Decision<ScopeDenialReason>;
40
+ /** Require every listed scope (denies on the first missing one, in SCOPES order). */
41
+ export declare function requireScopes(actorScopes: readonly Scope[], required: readonly Scope[]): Decision<ScopeDenialReason>;
42
+ //# sourceMappingURL=scopes.d.ts.map
package/dist/scopes.js ADDED
@@ -0,0 +1,71 @@
1
+ import { z } from "zod";
2
+ import { ALLOWED, denied } from "./decision.js";
3
+ /**
4
+ * Known scopes and role -> scope-bundle mapping (Phase 2 contract section 3,
5
+ * design section 19.3). `votes:write` (design section 19.2) is deferred with
6
+ * voting to Phase 3 and is intentionally not a known scope in this phase.
7
+ */
8
+ export const SCOPES = [
9
+ "chapters:read",
10
+ "annotations:read",
11
+ "annotations:write",
12
+ "work:read",
13
+ "work:claim",
14
+ "submissions:write",
15
+ "tokens:manage",
16
+ "members:manage",
17
+ ];
18
+ export const scopeSchema = z.enum(SCOPES);
19
+ export const ROLES = ["reader", "contributor", "editor", "maintainer"];
20
+ export const roleSchema = z.enum(ROLES);
21
+ const READER_SCOPES = ["chapters:read", "annotations:read"];
22
+ const CONTRIBUTOR_SCOPES = [...READER_SCOPES, "annotations:write"];
23
+ const EDITOR_SCOPES = [
24
+ ...CONTRIBUTOR_SCOPES,
25
+ "work:read",
26
+ "work:claim",
27
+ "submissions:write",
28
+ ];
29
+ const MAINTAINER_SCOPES = [
30
+ ...EDITOR_SCOPES,
31
+ "tokens:manage",
32
+ "members:manage",
33
+ ];
34
+ /** Role -> scope bundle (contract section 3). Bundles are cumulative. */
35
+ export const ROLE_SCOPES = Object.freeze({
36
+ reader: READER_SCOPES,
37
+ contributor: CONTRIBUTOR_SCOPES,
38
+ editor: EDITOR_SCOPES,
39
+ maintainer: MAINTAINER_SCOPES,
40
+ });
41
+ /** The scope bundle a membership role grants (human sessions use this directly). */
42
+ export function roleScopes(role) {
43
+ return ROLE_SCOPES[role];
44
+ }
45
+ /**
46
+ * An agent's effective scopes = token.scopes ∩ its membership role bundle
47
+ * (contract section 3). Result is deduplicated and in canonical SCOPES order.
48
+ */
49
+ export function effectiveScopes(tokenScopes, role) {
50
+ const token = new Set(tokenScopes);
51
+ const bundle = new Set(ROLE_SCOPES[role]);
52
+ return SCOPES.filter((scope) => token.has(scope) && bundle.has(scope));
53
+ }
54
+ /** Require a single scope among the actor's effective scopes. */
55
+ export function requireScope(actorScopes, required) {
56
+ if (actorScopes.includes(required)) {
57
+ return ALLOWED;
58
+ }
59
+ return denied("missing-scope", `actor lacks required scope "${required}"`);
60
+ }
61
+ /** Require every listed scope (denies on the first missing one, in SCOPES order). */
62
+ export function requireScopes(actorScopes, required) {
63
+ const have = new Set(actorScopes);
64
+ for (const scope of SCOPES) {
65
+ if (required.includes(scope) && !have.has(scope)) {
66
+ return denied("missing-scope", `actor lacks required scope "${scope}"`);
67
+ }
68
+ }
69
+ return ALLOWED;
70
+ }
71
+ //# sourceMappingURL=scopes.js.map
@@ -0,0 +1,87 @@
1
+ import { z } from "zod";
2
+ import { type WorkItemType } from "@authorbot/schemas";
3
+ import { type Decision } from "./decision.js";
4
+ /**
5
+ * Submission command validation and the work-item-type -> submission-type
6
+ * capability mapping (Phase 4 contract section 4, design section 12.5).
7
+ * This module enforces shape, sizes, and cross-field rules only; Markdown
8
+ * prose safety on `content` (no raw HTML, allowed URL schemes) stays with
9
+ * `@authorbot/markdown` at the API layer, and lease-token verification is
10
+ * the API's constant-time hash compare.
11
+ */
12
+ /** Phase 4 submission types (contract sections 1, 4). */
13
+ export declare const SUBMISSION_TYPES: readonly ["range_replacement", "block_replacement", "chapter_replacement"];
14
+ export declare const submissionTypeSchema: z.ZodEnum<{
15
+ range_replacement: "range_replacement";
16
+ block_replacement: "block_replacement";
17
+ chapter_replacement: "chapter_replacement";
18
+ }>;
19
+ export type SubmissionType = z.infer<typeof submissionTypeSchema>;
20
+ /** Task-bundle `submissionSchema` id per type (design section 15.3). */
21
+ export declare const SUBMISSION_SCHEMA_IDS: Readonly<Record<SubmissionType, string>>;
22
+ /**
23
+ * Which submission type a work-item type requires (contract section 4:
24
+ * range -> range_replacement, block -> block_replacement, chapter ->
25
+ * chapter_replacement). `null` means the type is claimable but has no
26
+ * submission flow in Phase 4 (`write_chapter`/`planning`, contract
27
+ * section 1). Resolved ambiguity: `resolve_conflict` items carry both texts
28
+ * and are resolved by submitting the merged chapter, so they take
29
+ * `chapter_replacement`.
30
+ */
31
+ export declare const WORK_ITEM_SUBMISSION_TYPES: Readonly<Record<WorkItemType, SubmissionType | null>>;
32
+ /** The submission type a work-item type requires, or null when none exists yet. */
33
+ export declare function requiredSubmissionType(type: WorkItemType): SubmissionType | null;
34
+ export type SubmissionTypeDenialReason = "submission-type-mismatch" | "submission-not-supported";
35
+ /**
36
+ * The "type matches work-item type" step of the contract section 4
37
+ * verification order. `write_chapter`/`planning` deny with
38
+ * `submission-not-supported` (deferred flow) so the API can surface an
39
+ * honest problem type instead of a generic mismatch.
40
+ */
41
+ export declare function checkSubmissionTypeMatches(workItemType: WorkItemType, submissionType: SubmissionType): Decision<SubmissionTypeDenialReason>;
42
+ /** Content cap (contract section 4): <= 512 KiB, measured in UTF-8 bytes. */
43
+ export declare const MAX_SUBMISSION_CONTENT_BYTES: number;
44
+ /** `sha256:` + 64 lowercase hex chars (task-bundle `contentHash` shape, section 15.3). */
45
+ export declare const CONTENT_HASH_REGEX: RegExp;
46
+ export declare const contentHashSchema: z.ZodString;
47
+ /**
48
+ * `POST /work-items/{id}/submissions` (contract section 4): the HTTP body
49
+ * `{ leaseId, leaseToken, type, baseRevision, baseContentHash, content,
50
+ * summary?, notes? }` merged with the route's `workItemId`. The
51
+ * `Idempotency-Key` header, lease/holder/token verification, work-item
52
+ * state, and base-matches-bundle checks are separate ordered steps
53
+ * (`checkLeaseActive`, `transitionWorkItemPhase4`,
54
+ * `checkSubmissionTypeMatches`, `checkSubmissionBase`).
55
+ */
56
+ export declare const submitWorkCommandSchema: z.ZodObject<{
57
+ type: z.ZodEnum<{
58
+ range_replacement: "range_replacement";
59
+ block_replacement: "block_replacement";
60
+ chapter_replacement: "chapter_replacement";
61
+ }>;
62
+ workItemId: z.ZodString;
63
+ leaseId: z.ZodString;
64
+ leaseToken: z.ZodString;
65
+ baseRevision: z.ZodNumber;
66
+ baseContentHash: z.ZodString;
67
+ content: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
68
+ summary: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
69
+ notes: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
70
+ }, z.core.$strict>;
71
+ export type SubmitWorkCommand = z.infer<typeof submitWorkCommandSchema>;
72
+ export type SubmissionBaseDenialReason = "base-revision-mismatch" | "base-hash-mismatch";
73
+ /**
74
+ * The "baseRevision + baseContentHash match the lease's bundle" step
75
+ * (contract section 4). Revision is checked before hash so the commoner
76
+ * staleness cause surfaces first. Hash comparison here is not secret
77
+ * material (both values are content hashes the client already holds), so a
78
+ * plain compare is correct — constant-time treatment is for lease tokens.
79
+ */
80
+ export declare function checkSubmissionBase(bundle: {
81
+ readonly baseRevision: number;
82
+ readonly baseContentHash: string;
83
+ }, submission: {
84
+ readonly baseRevision: number;
85
+ readonly baseContentHash: string;
86
+ }): Decision<SubmissionBaseDenialReason>;
87
+ //# sourceMappingURL=submission.d.ts.map
@@ -0,0 +1,132 @@
1
+ import { z } from "zod";
2
+ import { uuidv7Schema } from "@authorbot/schemas";
3
+ import { ALLOWED, denied } from "./decision.js";
4
+ import { bodySchema, utf8ByteLength } from "./commands.js";
5
+ import { leaseTokenSchema } from "./lease-token.js";
6
+ /**
7
+ * Submission command validation and the work-item-type -> submission-type
8
+ * capability mapping (Phase 4 contract section 4, design section 12.5).
9
+ * This module enforces shape, sizes, and cross-field rules only; Markdown
10
+ * prose safety on `content` (no raw HTML, allowed URL schemes) stays with
11
+ * `@authorbot/markdown` at the API layer, and lease-token verification is
12
+ * the API's constant-time hash compare.
13
+ */
14
+ /** Phase 4 submission types (contract sections 1, 4). */
15
+ export const SUBMISSION_TYPES = [
16
+ "range_replacement",
17
+ "block_replacement",
18
+ "chapter_replacement",
19
+ ];
20
+ export const submissionTypeSchema = z.enum(SUBMISSION_TYPES);
21
+ /** Task-bundle `submissionSchema` id per type (design section 15.3). */
22
+ export const SUBMISSION_SCHEMA_IDS = Object.freeze({
23
+ range_replacement: "authorbot.submission/range-replacement/v1",
24
+ block_replacement: "authorbot.submission/block-replacement/v1",
25
+ chapter_replacement: "authorbot.submission/chapter-replacement/v1",
26
+ });
27
+ /**
28
+ * Which submission type a work-item type requires (contract section 4:
29
+ * range -> range_replacement, block -> block_replacement, chapter ->
30
+ * chapter_replacement). `null` means the type is claimable but has no
31
+ * submission flow in Phase 4 (`write_chapter`/`planning`, contract
32
+ * section 1). Resolved ambiguity: `resolve_conflict` items carry both texts
33
+ * and are resolved by submitting the merged chapter, so they take
34
+ * `chapter_replacement`.
35
+ */
36
+ export const WORK_ITEM_SUBMISSION_TYPES = Object.freeze({
37
+ revise_range: "range_replacement",
38
+ revise_block: "block_replacement",
39
+ revise_chapter: "chapter_replacement",
40
+ resolve_conflict: "chapter_replacement",
41
+ write_chapter: null,
42
+ planning: null,
43
+ });
44
+ /** The submission type a work-item type requires, or null when none exists yet. */
45
+ export function requiredSubmissionType(type) {
46
+ return WORK_ITEM_SUBMISSION_TYPES[type];
47
+ }
48
+ /**
49
+ * The "type matches work-item type" step of the contract section 4
50
+ * verification order. `write_chapter`/`planning` deny with
51
+ * `submission-not-supported` (deferred flow) so the API can surface an
52
+ * honest problem type instead of a generic mismatch.
53
+ */
54
+ export function checkSubmissionTypeMatches(workItemType, submissionType) {
55
+ const required = WORK_ITEM_SUBMISSION_TYPES[workItemType];
56
+ if (required === null) {
57
+ return denied("submission-not-supported", `work items of type "${workItemType}" have no submission flow in Phase 4`);
58
+ }
59
+ if (required !== submissionType) {
60
+ return denied("submission-type-mismatch", `work items of type "${workItemType}" require a "${required}" submission`);
61
+ }
62
+ return ALLOWED;
63
+ }
64
+ /** Content cap (contract section 4): <= 512 KiB, measured in UTF-8 bytes. */
65
+ export const MAX_SUBMISSION_CONTENT_BYTES = 512 * 1024;
66
+ /** `sha256:` + 64 lowercase hex chars (task-bundle `contentHash` shape, section 15.3). */
67
+ export const CONTENT_HASH_REGEX = /^sha256:[0-9a-f]{64}$/;
68
+ export const contentHashSchema = z
69
+ .string()
70
+ .regex(CONTENT_HASH_REGEX, "must be 'sha256:' followed by 64 lowercase hex characters");
71
+ /**
72
+ * Replacement content: CRLF folded to LF (matching chapter storage) but NOT
73
+ * trimmed — leading/trailing whitespace is meaningful in a range
74
+ * replacement. Emptiness is a per-type rule handled in the command schema:
75
+ * an empty `range_replacement` is a deletion and legal; block and chapter
76
+ * replacements must be non-empty.
77
+ */
78
+ const submissionContentSchema = z
79
+ .string()
80
+ .transform((value) => value.replace(/\r\n/g, "\n"))
81
+ .refine((value) => utf8ByteLength(value) <= MAX_SUBMISSION_CONTENT_BYTES, `content must be at most ${MAX_SUBMISSION_CONTENT_BYTES} bytes of UTF-8`);
82
+ const submitWorkBase = {
83
+ workItemId: uuidv7Schema,
84
+ leaseId: uuidv7Schema,
85
+ leaseToken: leaseTokenSchema,
86
+ baseRevision: z.number().int().min(1),
87
+ baseContentHash: contentHashSchema,
88
+ content: submissionContentSchema,
89
+ /**
90
+ * `summary`/`notes` are unpinned by the contract; this package reuses the
91
+ * annotation body rule (normalized, non-empty, <= 32 KiB UTF-8).
92
+ */
93
+ summary: bodySchema.optional(),
94
+ notes: bodySchema.optional(),
95
+ };
96
+ /**
97
+ * `POST /work-items/{id}/submissions` (contract section 4): the HTTP body
98
+ * `{ leaseId, leaseToken, type, baseRevision, baseContentHash, content,
99
+ * summary?, notes? }` merged with the route's `workItemId`. The
100
+ * `Idempotency-Key` header, lease/holder/token verification, work-item
101
+ * state, and base-matches-bundle checks are separate ordered steps
102
+ * (`checkLeaseActive`, `transitionWorkItemPhase4`,
103
+ * `checkSubmissionTypeMatches`, `checkSubmissionBase`).
104
+ */
105
+ export const submitWorkCommandSchema = z
106
+ .strictObject({ ...submitWorkBase, type: submissionTypeSchema })
107
+ .superRefine((command, ctx) => {
108
+ if (command.type !== "range_replacement" && command.content.length === 0) {
109
+ ctx.addIssue({
110
+ code: "custom",
111
+ path: ["content"],
112
+ message: `content must not be empty for a ${command.type}`,
113
+ });
114
+ }
115
+ });
116
+ /**
117
+ * The "baseRevision + baseContentHash match the lease's bundle" step
118
+ * (contract section 4). Revision is checked before hash so the commoner
119
+ * staleness cause surfaces first. Hash comparison here is not secret
120
+ * material (both values are content hashes the client already holds), so a
121
+ * plain compare is correct — constant-time treatment is for lease tokens.
122
+ */
123
+ export function checkSubmissionBase(bundle, submission) {
124
+ if (submission.baseRevision !== bundle.baseRevision) {
125
+ return denied("base-revision-mismatch", `submission is against revision ${submission.baseRevision} but the lease was issued for revision ${bundle.baseRevision}`);
126
+ }
127
+ if (submission.baseContentHash !== bundle.baseContentHash) {
128
+ return denied("base-hash-mismatch", "submission base content hash does not match the lease's task bundle");
129
+ }
130
+ return ALLOWED;
131
+ }
132
+ //# sourceMappingURL=submission.js.map
@@ -0,0 +1,73 @@
1
+ import { z } from "zod";
2
+ import { type Decision } from "./decision.js";
3
+ /**
4
+ * Token and session VALUE rules only (Phase 2 contract section 3). This
5
+ * module is deliberately crypto-free: generating randomness and hashing
6
+ * (SHA-256, HMAC) are the API layer's job. Nothing here ever logs or embeds
7
+ * a full credential in an error message.
8
+ */
9
+ /** Agent token: `authorbot_` + 43 base64url chars (256-bit random). */
10
+ export declare const AGENT_TOKEN_PREFIX = "authorbot_";
11
+ export declare const AGENT_TOKEN_SECRET_LENGTH = 43;
12
+ export declare const AGENT_TOKEN_REGEX: RegExp;
13
+ export declare const agentTokenSchema: z.ZodString;
14
+ export declare function isAgentTokenFormat(value: string): boolean;
15
+ export type AgentTokenParseFailure = "bad-prefix" | "bad-length" | "bad-charset";
16
+ export type AgentTokenParseResult = {
17
+ readonly ok: true;
18
+ readonly secret: string;
19
+ } | {
20
+ readonly ok: false;
21
+ readonly reason: AgentTokenParseFailure;
22
+ };
23
+ /**
24
+ * Shape-check a presented credential and split off the secret part (which the
25
+ * API layer hashes for lookup). Failure results carry a reason only — never
26
+ * any fragment of the presented value.
27
+ */
28
+ export declare function parseAgentToken(value: string): AgentTokenParseResult;
29
+ /**
30
+ * Human session id: opaque 256-bit value (contract section 3). The contract
31
+ * does not pin an encoding; this package pins base64url (43 chars), matching
32
+ * the agent-token secret encoding, and the API must generate accordingly.
33
+ * Cookie signing (HMAC) is out of scope here.
34
+ */
35
+ export declare const SESSION_ID_LENGTH = 43;
36
+ export declare const SESSION_ID_REGEX: RegExp;
37
+ export declare const sessionIdSchema: z.ZodString;
38
+ export declare function isSessionIdFormat(value: string): boolean;
39
+ /** Agent-token TTL bounds (contract section 3: <= 90d, default 30d). */
40
+ export declare const MAX_TOKEN_TTL_DAYS = 90;
41
+ export declare const DEFAULT_TOKEN_TTL_DAYS = 30;
42
+ /** Human session TTL (contract section 3: 7d). */
43
+ export declare const SESSION_TTL_DAYS = 7;
44
+ /** Format a Date as the contract's RFC 3339 UTC second-precision timestamp. */
45
+ export declare function toTimestamp(date: Date): string;
46
+ export type TokenExpiryResult = {
47
+ readonly ok: true;
48
+ readonly expiresAt: string;
49
+ } | {
50
+ readonly ok: false;
51
+ readonly reason: "ttl-out-of-range";
52
+ };
53
+ /**
54
+ * Resolve an agent token's `expires_at` from mint time and requested TTL.
55
+ * Missing TTL means the 30-day default; anything outside 1..90 whole days is
56
+ * rejected rather than clamped.
57
+ */
58
+ export declare function resolveTokenExpiry(now: Date, requestedDays?: number): TokenExpiryResult;
59
+ /** Resolve a human session's `expires_at` (7 days from issue). */
60
+ export declare function resolveSessionExpiry(now: Date): string;
61
+ export type TokenInactiveReason = "revoked" | "expired";
62
+ /**
63
+ * Whether a stored token row is still usable at `now`. Revocation wins over
64
+ * expiry; expiry is inclusive (a token is expired at exactly `expires_at`).
65
+ */
66
+ export declare function checkTokenActive(token: {
67
+ expiresAt: string;
68
+ revokedAt?: string | null;
69
+ }, now: Date): Decision<TokenInactiveReason>;
70
+ /** `last_used_at` update throttle (contract section 3: at most once per minute). */
71
+ export declare const LAST_USED_UPDATE_INTERVAL_MS = 60000;
72
+ export declare function shouldUpdateLastUsed(lastUsedAt: string | null | undefined, now: Date): boolean;
73
+ //# sourceMappingURL=token.d.ts.map
package/dist/token.js ADDED
@@ -0,0 +1,102 @@
1
+ import { z } from "zod";
2
+ import { ALLOWED, denied } from "./decision.js";
3
+ /**
4
+ * Token and session VALUE rules only (Phase 2 contract section 3). This
5
+ * module is deliberately crypto-free: generating randomness and hashing
6
+ * (SHA-256, HMAC) are the API layer's job. Nothing here ever logs or embeds
7
+ * a full credential in an error message.
8
+ */
9
+ /** Agent token: `authorbot_` + 43 base64url chars (256-bit random). */
10
+ export const AGENT_TOKEN_PREFIX = "authorbot_";
11
+ export const AGENT_TOKEN_SECRET_LENGTH = 43;
12
+ const BASE64URL_CHAR = /^[A-Za-z0-9_-]+$/;
13
+ export const AGENT_TOKEN_REGEX = /^authorbot_[A-Za-z0-9_-]{43}$/;
14
+ export const agentTokenSchema = z
15
+ .string()
16
+ .regex(AGENT_TOKEN_REGEX, "must be 'authorbot_' followed by 43 base64url characters");
17
+ export function isAgentTokenFormat(value) {
18
+ return AGENT_TOKEN_REGEX.test(value);
19
+ }
20
+ /**
21
+ * Shape-check a presented credential and split off the secret part (which the
22
+ * API layer hashes for lookup). Failure results carry a reason only — never
23
+ * any fragment of the presented value.
24
+ */
25
+ export function parseAgentToken(value) {
26
+ if (!value.startsWith(AGENT_TOKEN_PREFIX)) {
27
+ return { ok: false, reason: "bad-prefix" };
28
+ }
29
+ const secret = value.slice(AGENT_TOKEN_PREFIX.length);
30
+ if (secret.length !== AGENT_TOKEN_SECRET_LENGTH) {
31
+ return { ok: false, reason: "bad-length" };
32
+ }
33
+ if (!BASE64URL_CHAR.test(secret)) {
34
+ return { ok: false, reason: "bad-charset" };
35
+ }
36
+ return { ok: true, secret };
37
+ }
38
+ /**
39
+ * Human session id: opaque 256-bit value (contract section 3). The contract
40
+ * does not pin an encoding; this package pins base64url (43 chars), matching
41
+ * the agent-token secret encoding, and the API must generate accordingly.
42
+ * Cookie signing (HMAC) is out of scope here.
43
+ */
44
+ export const SESSION_ID_LENGTH = 43;
45
+ export const SESSION_ID_REGEX = /^[A-Za-z0-9_-]{43}$/;
46
+ export const sessionIdSchema = z
47
+ .string()
48
+ .regex(SESSION_ID_REGEX, "must be 43 base64url characters (256-bit)");
49
+ export function isSessionIdFormat(value) {
50
+ return SESSION_ID_REGEX.test(value);
51
+ }
52
+ /** Agent-token TTL bounds (contract section 3: <= 90d, default 30d). */
53
+ export const MAX_TOKEN_TTL_DAYS = 90;
54
+ export const DEFAULT_TOKEN_TTL_DAYS = 30;
55
+ /** Human session TTL (contract section 3: 7d). */
56
+ export const SESSION_TTL_DAYS = 7;
57
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
58
+ /** Format a Date as the contract's RFC 3339 UTC second-precision timestamp. */
59
+ export function toTimestamp(date) {
60
+ if (Number.isNaN(date.getTime())) {
61
+ throw new RangeError("invalid Date");
62
+ }
63
+ return `${date.toISOString().slice(0, 19)}Z`;
64
+ }
65
+ /**
66
+ * Resolve an agent token's `expires_at` from mint time and requested TTL.
67
+ * Missing TTL means the 30-day default; anything outside 1..90 whole days is
68
+ * rejected rather than clamped.
69
+ */
70
+ export function resolveTokenExpiry(now, requestedDays) {
71
+ const days = requestedDays ?? DEFAULT_TOKEN_TTL_DAYS;
72
+ if (!Number.isInteger(days) || days < 1 || days > MAX_TOKEN_TTL_DAYS) {
73
+ return { ok: false, reason: "ttl-out-of-range" };
74
+ }
75
+ return { ok: true, expiresAt: toTimestamp(new Date(now.getTime() + days * MS_PER_DAY)) };
76
+ }
77
+ /** Resolve a human session's `expires_at` (7 days from issue). */
78
+ export function resolveSessionExpiry(now) {
79
+ return toTimestamp(new Date(now.getTime() + SESSION_TTL_DAYS * MS_PER_DAY));
80
+ }
81
+ /**
82
+ * Whether a stored token row is still usable at `now`. Revocation wins over
83
+ * expiry; expiry is inclusive (a token is expired at exactly `expires_at`).
84
+ */
85
+ export function checkTokenActive(token, now) {
86
+ if (token.revokedAt !== undefined && token.revokedAt !== null) {
87
+ return denied("revoked", "token has been revoked");
88
+ }
89
+ if (now.getTime() >= Date.parse(token.expiresAt)) {
90
+ return denied("expired", "token has expired");
91
+ }
92
+ return ALLOWED;
93
+ }
94
+ /** `last_used_at` update throttle (contract section 3: at most once per minute). */
95
+ export const LAST_USED_UPDATE_INTERVAL_MS = 60_000;
96
+ export function shouldUpdateLastUsed(lastUsedAt, now) {
97
+ if (lastUsedAt === undefined || lastUsedAt === null) {
98
+ return true;
99
+ }
100
+ return now.getTime() - Date.parse(lastUsedAt) >= LAST_USED_UPDATE_INTERVAL_MS;
101
+ }
102
+ //# sourceMappingURL=token.js.map
package/dist/vote.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ import { type AnnotationKind } from "@authorbot/schemas";
3
+ import { type Decision } from "./decision.js";
4
+ /**
5
+ * Vote commands (Phase 3 contract section 2):
6
+ * `PUT /v1/projects/{p}/annotations/{id}/vote` with `{ value }`, `DELETE`
7
+ * clears. One current vote per actor is a DB uniqueness concern (upsert);
8
+ * this module pins the command shapes and the suggestion-only rule.
9
+ */
10
+ /** Vote values (contract section 2; mirrors `authorbot.instance/v1` `votes.values`). */
11
+ export declare const VOTE_VALUES: readonly ["approve", "reject", "abstain"];
12
+ export declare const voteValueSchema: z.ZodEnum<{
13
+ approve: "approve";
14
+ reject: "reject";
15
+ abstain: "abstain";
16
+ }>;
17
+ export type VoteValue = z.infer<typeof voteValueSchema>;
18
+ /** `PUT .../annotations/{annotationId}/vote` — route param + body merged. */
19
+ export declare const castVoteCommandSchema: z.ZodObject<{
20
+ annotationId: z.ZodString;
21
+ value: z.ZodEnum<{
22
+ approve: "approve";
23
+ reject: "reject";
24
+ abstain: "abstain";
25
+ }>;
26
+ }, z.core.$strict>;
27
+ export type CastVoteCommand = z.infer<typeof castVoteCommandSchema>;
28
+ /** `DELETE .../annotations/{annotationId}/vote` — the command is the route param. */
29
+ export declare const clearVoteCommandSchema: z.ZodObject<{
30
+ annotationId: z.ZodString;
31
+ }, z.core.$strict>;
32
+ export type ClearVoteCommand = z.infer<typeof clearVoteCommandSchema>;
33
+ export type VoteDenialReason = "not-a-suggestion";
34
+ /**
35
+ * Suggestion-only guard (contract section 2: votes on comments → 422). Votes
36
+ * stay legal after `open` — sticky decisions require tracking vote changes on
37
+ * `work_item_created` annotations (contract section 4) — so annotation status
38
+ * is deliberately not checked here; `votes:write` scope enforcement is the
39
+ * API layer's (`requireScope`).
40
+ */
41
+ export declare function authorizeVote(input: {
42
+ annotationKind: AnnotationKind;
43
+ }): Decision<VoteDenialReason>;
44
+ //# sourceMappingURL=vote.d.ts.map