@oxy.so/contracts 4.16.0 → 4.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/dist/cjs/accountAccess.js +72 -0
  2. package/dist/cjs/accountEmail.js +12 -34
  3. package/dist/cjs/accountGraph.js +22 -320
  4. package/dist/cjs/agency.js +112 -1
  5. package/dist/cjs/agentKeys.js +11 -13
  6. package/dist/cjs/applicationRoles.js +211 -0
  7. package/dist/cjs/client/accountEmail.js +48 -0
  8. package/dist/cjs/client/accountGraph.js +323 -0
  9. package/dist/cjs/client/agentKeys.js +77 -0
  10. package/dist/cjs/client/check.js +475 -0
  11. package/dist/cjs/client/deviceBoot.js +22 -0
  12. package/dist/cjs/client/deviceDirectory.js +51 -0
  13. package/dist/cjs/client/deviceSession.js +63 -0
  14. package/dist/cjs/client/externalIdentity.js +19 -0
  15. package/dist/cjs/client/identity.js +45 -0
  16. package/dist/cjs/client/identityLink.js +55 -0
  17. package/dist/cjs/client/identityProof.js +109 -0
  18. package/dist/cjs/client/index.js +211 -0
  19. package/dist/cjs/client/organizations.js +114 -0
  20. package/dist/cjs/client/products.js +153 -0
  21. package/dist/cjs/client/recommendations.js +33 -0
  22. package/dist/cjs/client/reputation.js +41 -0
  23. package/dist/cjs/client/safety.js +196 -0
  24. package/dist/cjs/client/sessionStatus.js +23 -0
  25. package/dist/cjs/client/signIn.js +159 -0
  26. package/dist/cjs/client/userResponse.js +15 -0
  27. package/dist/cjs/client/username.js +173 -0
  28. package/dist/cjs/deviceSession.js +3 -16
  29. package/dist/cjs/identityLink.js +13 -34
  30. package/dist/cjs/identityProof.js +18 -106
  31. package/dist/cjs/index.js +13 -3
  32. package/dist/cjs/organizations.js +21 -41
  33. package/dist/cjs/reputation.js +3 -34
  34. package/dist/cjs/safety.js +430 -1
  35. package/dist/cjs/serviceToken.js +14 -0
  36. package/dist/cjs/signIn.js +33 -111
  37. package/dist/cjs/username.js +23 -158
  38. package/dist/esm/accountAccess.js +69 -0
  39. package/dist/esm/accountEmail.js +4 -32
  40. package/dist/esm/accountGraph.js +4 -309
  41. package/dist/esm/agency.js +111 -0
  42. package/dist/esm/agentKeys.js +4 -10
  43. package/dist/esm/applicationRoles.js +207 -0
  44. package/dist/esm/client/accountEmail.js +45 -0
  45. package/dist/esm/client/accountGraph.js +314 -0
  46. package/dist/esm/client/agentKeys.js +74 -0
  47. package/dist/esm/client/check.js +436 -0
  48. package/dist/esm/client/deviceBoot.js +19 -0
  49. package/dist/esm/client/deviceDirectory.js +48 -0
  50. package/dist/esm/client/deviceSession.js +60 -0
  51. package/dist/esm/client/externalIdentity.js +16 -0
  52. package/dist/esm/client/identity.js +42 -0
  53. package/dist/esm/client/identityLink.js +50 -0
  54. package/dist/esm/client/identityProof.js +104 -0
  55. package/dist/esm/client/index.js +37 -0
  56. package/dist/esm/client/organizations.js +111 -0
  57. package/dist/esm/client/products.js +150 -0
  58. package/dist/esm/client/recommendations.js +30 -0
  59. package/dist/esm/client/reputation.js +37 -0
  60. package/dist/esm/client/safety.js +193 -0
  61. package/dist/esm/client/sessionStatus.js +20 -0
  62. package/dist/esm/client/signIn.js +153 -0
  63. package/dist/esm/client/userResponse.js +12 -0
  64. package/dist/esm/client/username.js +167 -0
  65. package/dist/esm/deviceSession.js +1 -15
  66. package/dist/esm/identityLink.js +4 -27
  67. package/dist/esm/identityProof.js +4 -99
  68. package/dist/esm/index.js +4 -1
  69. package/dist/esm/organizations.js +4 -31
  70. package/dist/esm/reputation.js +1 -32
  71. package/dist/esm/safety.js +423 -0
  72. package/dist/esm/serviceToken.js +11 -0
  73. package/dist/esm/signIn.js +4 -96
  74. package/dist/esm/username.js +4 -148
  75. package/dist/types/accountAccess.d.ts +128 -0
  76. package/dist/types/accountEmail.d.ts +4 -36
  77. package/dist/types/accountGraph.d.ts +12 -208
  78. package/dist/types/agency.d.ts +351 -26
  79. package/dist/types/agentKeys.d.ts +14 -12
  80. package/dist/types/applicationRoles.d.ts +438 -0
  81. package/dist/types/client/accountEmail.d.ts +44 -0
  82. package/dist/types/client/accountGraph.d.ts +203 -0
  83. package/dist/types/client/agentKeys.d.ts +113 -0
  84. package/dist/types/client/check.d.ts +187 -0
  85. package/dist/types/client/deviceBoot.d.ts +6 -0
  86. package/dist/types/client/deviceDirectory.d.ts +134 -0
  87. package/dist/types/client/deviceSession.d.ts +91 -0
  88. package/dist/types/client/externalIdentity.d.ts +14 -0
  89. package/dist/types/client/identity.d.ts +8 -0
  90. package/dist/types/client/identityLink.d.ts +21 -0
  91. package/dist/types/client/identityProof.d.ts +73 -0
  92. package/dist/types/client/index.d.ts +37 -0
  93. package/dist/types/client/organizations.d.ts +167 -0
  94. package/dist/types/client/products.d.ts +145 -0
  95. package/dist/types/client/recommendations.d.ts +27 -0
  96. package/dist/types/client/reputation.d.ts +28 -0
  97. package/dist/types/client/safety.d.ts +213 -0
  98. package/dist/types/client/sessionStatus.d.ts +19 -0
  99. package/dist/types/client/signIn.d.ts +107 -0
  100. package/dist/types/client/userResponse.d.ts +7 -0
  101. package/dist/types/client/username.d.ts +126 -0
  102. package/dist/types/deviceDirectory.d.ts +28 -28
  103. package/dist/types/deviceSession.d.ts +5 -16
  104. package/dist/types/email/messages.d.ts +2 -2
  105. package/dist/types/externalIdentity.d.ts +15 -15
  106. package/dist/types/identityLink.d.ts +3 -11
  107. package/dist/types/identityProof.d.ts +2 -65
  108. package/dist/types/index.d.ts +5 -2
  109. package/dist/types/inference/accountBilling.d.ts +2 -2
  110. package/dist/types/inference/catalogue.d.ts +22 -22
  111. package/dist/types/inference/decisions.d.ts +10 -10
  112. package/dist/types/inference/embeddings.d.ts +16 -16
  113. package/dist/types/inference/errors.d.ts +8 -8
  114. package/dist/types/inference/inbox.d.ts +8 -8
  115. package/dist/types/inference/privateAutoExecution.d.ts +6 -6
  116. package/dist/types/inference/providerConnection.d.ts +4 -4
  117. package/dist/types/inference/realtime.d.ts +92 -92
  118. package/dist/types/inference/request.d.ts +48 -48
  119. package/dist/types/inference/streamEvents.d.ts +16 -16
  120. package/dist/types/inference/usage.d.ts +4 -4
  121. package/dist/types/oauth.d.ts +2 -2
  122. package/dist/types/organizations.d.ts +8 -10
  123. package/dist/types/products/access.d.ts +4 -4
  124. package/dist/types/products/billing.d.ts +3 -0
  125. package/dist/types/reputation.d.ts +1 -11
  126. package/dist/types/safety.d.ts +2374 -2
  127. package/dist/types/serviceToken.d.ts +11 -0
  128. package/dist/types/sessionStatus.d.ts +2 -2
  129. package/dist/types/signIn.d.ts +11 -94
  130. package/dist/types/userResponse.d.ts +22 -22
  131. package/dist/types/username.d.ts +1 -96
  132. package/package.json +24 -20
  133. package/dist/cjs/.tsbuildinfo +0 -1
  134. package/dist/esm/.tsbuildinfo +0 -1
  135. package/dist/types/.tsbuildinfo +0 -1
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Application roles — the wire contract for permissions an APPLICATION declares
3
+ * about its own users, and for the grants that give them to Oxy users.
4
+ *
5
+ * Oxy decides who holds an app's operator powers (who may run Mercaria's
6
+ * payments desk, moderate GoWay, administer Homiio), so no app keeps an
7
+ * allow-list of Oxy user ids in its environment. The app declares a closed
8
+ * vocabulary of keys, its owners grant them to people, and the app's backend
9
+ * asks Oxy which of them the human behind a request holds
10
+ * (`GET /applications/:appId/roles/me`, read through `@oxy.so/core/server`).
11
+ *
12
+ * ## Why an application key can never collide with an Oxy permission
13
+ *
14
+ * Every permission Oxy itself checks (`app:update`, `apps:create`,
15
+ * `credentials:rotate`, every scope) is spelled with a COLON. An application key
16
+ * is dot-separated lowercase segments and cannot contain one, so the two
17
+ * vocabularies are disjoint by syntax, not by convention. They are also stored
18
+ * apart (scoped to one application id) and no Oxy gate ever reads an
19
+ * application key. A key means something only to the application that declared
20
+ * it.
21
+ *
22
+ * The pattern is exported as a SOURCE string because the API renders the same
23
+ * text into a Postgres CHECK; JavaScript's `RegExp` and Postgres' ARE agree on
24
+ * everything it uses (anchors, classes, a capturing group, a bounded repeat).
25
+ *
26
+ * Platform-agnostic — zod only, no react/react-native/expo.
27
+ */
28
+ import { z } from 'zod';
29
+ /**
30
+ * Two to four dot-separated segments, each a lowercase letter followed by
31
+ * lowercase letters, digits or underscores: `payments.operate`,
32
+ * `catalog.review.publish`. At least two segments so a key always names an
33
+ * area and an action; never a colon (see the header).
34
+ */
35
+ export const APPLICATION_PERMISSION_KEY_PATTERN_SOURCE = '^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*){1,3}$';
36
+ export const APPLICATION_PERMISSION_KEY_PATTERN = new RegExp(APPLICATION_PERMISSION_KEY_PATTERN_SOURCE);
37
+ export const APPLICATION_PERMISSION_KEY_MAX_LENGTH = 64;
38
+ export const APPLICATION_PERMISSION_DESCRIPTION_MAX_LENGTH = 280;
39
+ /** How many live keys one application may declare. Operator powers are few. */
40
+ export const APPLICATION_PERMISSIONS_MAX_DECLARED = 64;
41
+ export const APPLICATION_ROLE_REASON_MAX_LENGTH = 500;
42
+ export const APPLICATION_ROLE_GRANTS_PAGE_DEFAULT = 50;
43
+ export const APPLICATION_ROLE_GRANTS_PAGE_MAX = 200;
44
+ /**
45
+ * The `error` codes the application-roles routes answer beyond the generic
46
+ * ones (`BAD_REQUEST`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`). Renaming one
47
+ * is a breaking change.
48
+ */
49
+ export const APPLICATION_ROLE_ERROR_CODES = {
50
+ /** 403: a grant naming yourself on an application you do not own outright. */
51
+ selfGrantForbidden: 'SELF_GRANT_FORBIDDEN',
52
+ /** 422: the key is not a live declared permission of this application. */
53
+ unknownPermission: 'UNKNOWN_PERMISSION',
54
+ /** 422: the grantee is not a person (an organization, project, bot or channel). */
55
+ granteeNotAPerson: 'GRANTEE_NOT_A_PERSON',
56
+ /** 409: the grantee already holds the key; `details.grantId` names the live grant. */
57
+ alreadyGranted: 'ALREADY_GRANTED',
58
+ /** 409: the grant was already revoked. */
59
+ alreadyRevoked: 'ALREADY_REVOKED',
60
+ /** 403: the bearer's session belongs to a DIFFERENT application. */
61
+ sessionApplicationMismatch: 'SESSION_APPLICATION_MISMATCH',
62
+ };
63
+ /** Whether `value` is a well-formed application permission key. */
64
+ export function isApplicationPermissionKey(value) {
65
+ return (typeof value === 'string' &&
66
+ value.length <= APPLICATION_PERMISSION_KEY_MAX_LENGTH &&
67
+ APPLICATION_PERMISSION_KEY_PATTERN.test(value));
68
+ }
69
+ export const applicationPermissionKeySchema = z
70
+ .string()
71
+ .max(APPLICATION_PERMISSION_KEY_MAX_LENGTH)
72
+ .regex(APPLICATION_PERMISSION_KEY_PATTERN, 'A permission key is 2-4 dot-separated lowercase segments, e.g. payments.operate');
73
+ /** Plain text: no control characters other than a newline or a tab. */
74
+ function plainText(max) {
75
+ return z
76
+ .string()
77
+ .trim()
78
+ .min(1)
79
+ .max(max)
80
+ .refine((value) => Array.from(value).every((character) => {
81
+ const code = character.charCodeAt(0);
82
+ return (code >= 32 && code !== 127) || character === '\n' || character === '\t';
83
+ }), 'Must be plain text');
84
+ }
85
+ const id = z.string().min(1).max(128);
86
+ const isoTimestamp = z.string().min(1);
87
+ const reasonSchema = plainText(APPLICATION_ROLE_REASON_MAX_LENGTH);
88
+ /** One key as the application's owners declare it. */
89
+ export const applicationPermissionDeclarationSchema = z
90
+ .object({
91
+ key: applicationPermissionKeySchema,
92
+ description: plainText(APPLICATION_PERMISSION_DESCRIPTION_MAX_LENGTH),
93
+ })
94
+ .strict();
95
+ /**
96
+ * `PUT /applications/:appId/roles/permissions` — the WHOLE vocabulary.
97
+ *
98
+ * Declarative: a key present here and not live is added, a live key absent
99
+ * here is retired (and every grant of it revoked), a live key present here
100
+ * keeps its identity and takes the new description. Sending the same body twice
101
+ * changes nothing.
102
+ */
103
+ export const declareApplicationPermissionsRequestSchema = z
104
+ .object({
105
+ permissions: z
106
+ .array(applicationPermissionDeclarationSchema)
107
+ .max(APPLICATION_PERMISSIONS_MAX_DECLARED),
108
+ })
109
+ .strict()
110
+ .superRefine((value, ctx) => {
111
+ const seen = new Set();
112
+ value.permissions.forEach((permission, index) => {
113
+ if (seen.has(permission.key)) {
114
+ ctx.addIssue({
115
+ code: z.ZodIssueCode.custom,
116
+ path: ['permissions', index, 'key'],
117
+ message: `Duplicate permission key ${permission.key}`,
118
+ });
119
+ }
120
+ seen.add(permission.key);
121
+ });
122
+ });
123
+ /** A declared key. Retired keys are history: they confer nothing. */
124
+ export const applicationPermissionSchema = z.object({
125
+ key: applicationPermissionKeySchema,
126
+ description: z.string(),
127
+ declaredAt: isoTimestamp,
128
+ declaredByUserId: id.nullable(),
129
+ retiredAt: isoTimestamp.nullable(),
130
+ retiredByUserId: id.nullable(),
131
+ });
132
+ export const applicationPermissionListSchema = z.object({
133
+ items: z.array(applicationPermissionSchema),
134
+ });
135
+ export const declareApplicationPermissionsResponseSchema = applicationPermissionListSchema.extend({
136
+ /** Keys that became live with this declaration. */
137
+ added: z.array(applicationPermissionKeySchema),
138
+ /** Keys this declaration retired. */
139
+ retired: z.array(applicationPermissionKeySchema),
140
+ /** Live keys whose description changed. */
141
+ described: z.array(applicationPermissionKeySchema),
142
+ /** Live grants revoked because their key was retired. */
143
+ revokedGrants: z.number().int().nonnegative(),
144
+ });
145
+ /** One grant of one key to one Oxy user. Revocation is a stamp, never a delete. */
146
+ export const applicationRoleGrantSchema = z.object({
147
+ id,
148
+ applicationId: id,
149
+ permission: applicationPermissionKeySchema,
150
+ userId: id,
151
+ /** The grantee's handle, for display. */
152
+ username: z.string().nullable(),
153
+ /** The HUMAN who granted it; null once that account was erased. */
154
+ grantedByUserId: id.nullable(),
155
+ grantedAt: isoTimestamp,
156
+ grantReason: z.string().nullable(),
157
+ revokedAt: isoTimestamp.nullable(),
158
+ revokedByUserId: id.nullable(),
159
+ revokeReason: z.string().nullable(),
160
+ });
161
+ export const applicationRoleGrantListSchema = z.object({
162
+ items: z.array(applicationRoleGrantSchema),
163
+ nextCursor: z.string().nullable(),
164
+ });
165
+ /** `POST /applications/:appId/roles/grants` — name the grantee by id OR handle. */
166
+ export const createApplicationRoleGrantRequestSchema = z
167
+ .object({
168
+ userId: id.optional(),
169
+ username: z.string().trim().min(1).max(64).optional(),
170
+ permission: applicationPermissionKeySchema,
171
+ reason: reasonSchema.optional(),
172
+ })
173
+ .strict()
174
+ .refine((value) => (value.userId === undefined) !== (value.username === undefined), {
175
+ message: 'Name the grantee with exactly one of userId or username',
176
+ path: ['userId'],
177
+ });
178
+ /** `POST /applications/:appId/roles/grants/:grantId/revoke`. */
179
+ export const revokeApplicationRoleGrantRequestSchema = z
180
+ .object({
181
+ reason: reasonSchema.optional(),
182
+ })
183
+ .strict();
184
+ /** `GET /applications/:appId/roles/grants` query. */
185
+ export const listApplicationRoleGrantsQuerySchema = z
186
+ .object({
187
+ permission: applicationPermissionKeySchema.optional(),
188
+ userId: id.optional(),
189
+ includeRevoked: z
190
+ .enum(['true', 'false'])
191
+ .transform((value) => value === 'true')
192
+ .optional(),
193
+ limit: z.coerce.number().int().min(1).max(APPLICATION_ROLE_GRANTS_PAGE_MAX).optional(),
194
+ cursor: z.string().min(1).max(512).optional(),
195
+ })
196
+ .strict();
197
+ /**
198
+ * `GET /applications/:appId/roles/me` — the keys the HUMAN behind the bearer
199
+ * holds for this application, in no particular order. `userId` is that human
200
+ * (the operator), which is NOT the account the session speaks as when a person
201
+ * is acting as an organization.
202
+ */
203
+ export const applicationRolesMeSchema = z.object({
204
+ applicationId: id,
205
+ userId: id,
206
+ permissions: z.array(applicationPermissionKeySchema),
207
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Account-email constants and response checks, without Zod. Schemas: `../accountEmail.ts`.
3
+ *
4
+ * Part of `@oxy.so/contracts/client`: never import `zod` here (see `./check.ts`).
5
+ */
6
+ import { email, exact, int, maxLength, minLength, number, object, pattern, positive, string, toLowerCase, trim, } from './check.js';
7
+ /**
8
+ * - `signup`: above.
9
+ * - `signin`: the code (and link) of an email sign-in (`POST /auth/signin/email/start`).
10
+ * - `reauth`: a signed-in person proving it is them before a sensitive step
11
+ * (`POST /users/me/reauth/email`): a password, an authenticator, deleting the
12
+ * account, linking Commons.
13
+ */
14
+ export const EMAIL_VERIFICATION_PURPOSES = ['signup', 'signin', 'reauth'];
15
+ /** Digits in a code. */
16
+ export const EMAIL_CODE_LENGTH = 6;
17
+ /** How long a code can be confirmed. */
18
+ export const EMAIL_CODE_TTL_MS = 10 * 60 * 1000;
19
+ /** Wrong codes before a verification is spent and a new code is needed. */
20
+ export const EMAIL_CODE_MAX_ATTEMPTS = 5;
21
+ /** How long a confirmed code's ticket can be spent. */
22
+ export const EMAIL_TICKET_TTL_MS = 15 * 60 * 1000;
23
+ /**
24
+ * Stable error codes (`error.code` in the API error body). Clients map these
25
+ * through their localization, never the English message.
26
+ */
27
+ export const EMAIL_VERIFICATION_ERROR_CODES = {
28
+ /** The code is wrong, or its verification expired or was spent. */
29
+ codeInvalid: 'EMAIL_CODE_INVALID',
30
+ /** Too many wrong codes: request a new one. */
31
+ tooManyAttempts: 'EMAIL_CODE_TOO_MANY_ATTEMPTS',
32
+ /** The ticket is unknown, expired, spent, or for another email or purpose. */
33
+ ticketInvalid: 'EMAIL_TICKET_INVALID',
34
+ /** A sign-up without a confirmed email. */
35
+ ticketRequired: 'EMAIL_TICKET_REQUIRED',
36
+ /** This server cannot send mail. */
37
+ unavailable: 'EMAIL_UNAVAILABLE',
38
+ };
39
+ /* -------------------------------------------------------------------------- */
40
+ /* Checks */
41
+ /* -------------------------------------------------------------------------- */
42
+ export const emailAddressCheck = string(trim, toLowerCase, minLength(3), maxLength(254), email);
43
+ export const emailTicketCheck = string(trim, pattern(/^[A-Za-z0-9_-]{43}$/, 'ticket must be 32 bytes of base64url'));
44
+ export const emailVerificationStartResponseCheck = exact()(object({ verificationId: string(minLength(1), maxLength(64)), expiresAt: number(int, positive) }));
45
+ export const emailVerificationConfirmResponseCheck = exact()(object({ ticket: emailTicketCheck, expiresAt: number(int, positive) }));
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Account kinds and categories, without Zod. Schemas: `../accountGraph.ts`.
3
+ *
4
+ * Part of `@oxy.so/contracts/client`: never import `zod` here (see `./check.ts`).
5
+ */
6
+ /**
7
+ * The union is spelled out above and the array proves coverage BOTH ways
8
+ * (`satisfies` here, the `Gap` alias below) — the same shape this package's
9
+ * `ACCOUNT_CATEGORY_IDS` / `TRUST_TIERS` pairs use, and the one
10
+ * `db/schema/users.ts` mirrors to keep the `users_kind_check` CHECK honest.
11
+ *
12
+ * Deriving the union from the array instead would cost nothing here and be paid
13
+ * by consumers: `kind` travels into `@oxy.so/services` on every device-directory
14
+ * context (`deviceContextSchema.kind` → `DeviceContext` → the switcher rows),
15
+ * and an indexed-access type is materially more expensive to check there than a
16
+ * literal union.
17
+ */
18
+ export const ACCOUNT_KINDS = [
19
+ 'personal',
20
+ 'organization',
21
+ 'project',
22
+ 'bot',
23
+ 'channel',
24
+ ];
25
+ export const CHILD_ACCOUNT_KINDS = [
26
+ 'organization',
27
+ 'project',
28
+ 'bot',
29
+ 'channel',
30
+ ];
31
+ /**
32
+ * Whether an account of this kind may be the SUBJECT OF A DELEGATION — an
33
+ * application acting as it on some person's authority. `POST /internal/accounts/
34
+ * :id/service-switch` and the OAuth delegated subject both gate on this.
35
+ *
36
+ * It is NOT the question an account switcher asks. See
37
+ * {@link isOperatorSwitchTargetKind}, and the note below on why the difference
38
+ * is `bot`.
39
+ *
40
+ * Two kinds are refused, for opposite reasons:
41
+ *
42
+ * - `personal` is a human login, so assuming it would be impersonation.
43
+ * - `channel` is a CONTENT identity, not an operating one. A channel exists so
44
+ * that posts can be authored BY it; it is never a seat anybody occupies. Its
45
+ * operators act on it through their own membership, and an application
46
+ * publishes to it with its own credential. Refusing act-as is what makes
47
+ * "no login, ever" structural rather than incidental: no session can be
48
+ * minted whose subject is a channel, so no bearer exists that could add an
49
+ * auth method to one (every auth-method write resolves its target from the
50
+ * authenticated subject, never from a parameter).
51
+ *
52
+ * Consumers must gate on this predicate rather than testing `kind === 'personal'`,
53
+ * which silently admits every kind added after it was written.
54
+ *
55
+ * Being act-as-eligible is NOT being without an identity of one's own. A bot is
56
+ * act-as-eligible AND its own actor (`accountKindActorNature` → `'agent'` in
57
+ * `accountSubject.ts`): an application acting as it on a person's authority
58
+ * records that person as the actor, and the bot acting with nobody operating it
59
+ * records the bot. Neither ever records the bot's owner in the bot's place.
60
+ */
61
+ export function isDelegatedActAsEligibleKind(kind) {
62
+ return kind === 'organization' || kind === 'project' || kind === 'bot';
63
+ }
64
+ /**
65
+ * Whether a PERSON may switch into an account of this kind — become it, in an
66
+ * account switcher, for the rest of their session.
67
+ *
68
+ * ## Why this is not the same question as {@link isDelegatedActAsEligibleKind}
69
+ *
70
+ * The two differ on exactly one kind, `bot`, and that difference is the whole
71
+ * reason both exist.
72
+ *
73
+ * **A bot is not something you become. It is somebody of its own.** It is an
74
+ * agent's identity, with its own resources, roles, plan and balance, and it is
75
+ * the actor of what it does unoperated. A person may still act AS it on their
76
+ * own authority — that is delegation, recorded with the person as actor, and it
77
+ * is what {@link isDelegatedActAsEligibleKind} admits it for. What a person
78
+ * never does is occupy its seat. This is a refusal of a SEAT, not a capability
79
+ * restriction: nothing a bot may do depends on this predicate.
80
+ *
81
+ * Handing a person the bot's seat instead inverts that. It puts a human inside
82
+ * the identity that exists to act without one, and it does so on the human's own
83
+ * device, next to their personal login — which is precisely what happened: a
84
+ * `bot` account held a live session on a person's device, offered to them by a
85
+ * switcher that had asked the delegation question by mistake.
86
+ *
87
+ * `channel` is refused here as well, for the reason set out above, and
88
+ * `personal` because assuming somebody else's login is impersonation.
89
+ *
90
+ * ## This is the narrower predicate, deliberately
91
+ *
92
+ * Everything a person may become, a service may also act as; the reverse does
93
+ * not hold. A caller that is unsure which question it is asking wants THIS one:
94
+ * being wrong here withholds an affordance, while being wrong the other way
95
+ * hands out a seat.
96
+ */
97
+ export function isOperatorSwitchTargetKind(kind) {
98
+ return kind === 'organization' || kind === 'project';
99
+ }
100
+ /**
101
+ * Narrow an unknown value to an {@link AccountKind}.
102
+ *
103
+ * The user-DTO serializers read from structurally-permissive `unknown` sources
104
+ * (a Drizzle row, a raw document, an already-formatted object), so each one
105
+ * would otherwise hand-roll this check and they would drift on what counts.
106
+ */
107
+ export function isAccountKind(value) {
108
+ return typeof value === 'string' && ACCOUNT_KINDS.includes(value);
109
+ }
110
+ // ===========================================================================
111
+ // Account categories
112
+ //
113
+ // Four rules govern this taxonomy. Each one is here because the obvious
114
+ // alternative fails silently rather than loudly.
115
+ //
116
+ // 1. AN ID IS AN OPAQUE, IMMUTABLE SLUG — the LABEL is not stored anywhere.
117
+ // Every value below is an identifier that no rename may ever change. The
118
+ // human-readable label lives in each client's own translation catalogue,
119
+ // keyed by the id. Re-labelling `agency` from "Real estate agency" to
120
+ // "Agency" is therefore a one-line locale edit that touches no row; moving
121
+ // the label into the column or the DTO would make the same edit a data
122
+ // migration, and would pin every reader to the language of whoever chose it.
123
+ //
124
+ // 2. THE PRIMARY CATEGORY IS THE FIRST ELEMENT of an ordered list. Not a
125
+ // separate flag, and not a second field: a flag admits two primaries and
126
+ // admits none, and ordering makes both unrepresentable. The cost is that
127
+ // ORDER IS DATA — anything that re-serializes, sorts or de-duplicates this
128
+ // list can change which category is primary without erroring, so no layer
129
+ // between the column and the client may reorder it.
130
+ //
131
+ // 3. THE VOCABULARY IS APPEND-ONLY. `ACCOUNT_CATEGORY_IDS` may gain ids and may
132
+ // never lose one, because rows already carry the ids it holds. Withdrawing a
133
+ // category means listing it in {@link RETIRED_ACCOUNT_CATEGORY_IDS}, which
134
+ // removes it from the picker while leaving it readable and re-writable. The
135
+ // database enforces the same asymmetry: its CHECK is re-evaluated on EVERY
136
+ // update to a row, so narrowing the allowed set makes an unrelated write —
137
+ // saving a bio — fail on any account that had picked the withdrawn value.
138
+ // Measured on a real Postgres, `NOT VALID` included; it does not help.
139
+ //
140
+ // 4. THE SET IS CLOSED. An id nobody can validate is an id no client can render:
141
+ // the label comes from a translation key derived from the id, so an invented
142
+ // value paints as a blank or a raw slug in every app at once. Closing it also
143
+ // costs nothing that rule 3 does not already charge — adding a category needs
144
+ // a migration to widen the CHECK whether or not the enum exists, so an open
145
+ // list would buy back no work, only the validation.
146
+ // ===========================================================================
147
+ /**
148
+ * Every account category, by stable id.
149
+ *
150
+ * Grouped by comment for readability only; the storage, the wire and the picker
151
+ * all treat this as one flat list. `other` is the escape hatch for an account
152
+ * that fits nothing here.
153
+ *
154
+ * TO ADD ONE: append an id (lowercase ASCII, `snake_case`) here, publish
155
+ * `@oxy.so/contracts`, then ship a migration that widens
156
+ * `users_account_categories_check` — never edit an existing migration — and add
157
+ * an `accounts.accountCategory.<id>` label to each client's locales.
158
+ *
159
+ * TO WITHDRAW ONE: leave the id here and add it to
160
+ * {@link RETIRED_ACCOUNT_CATEGORY_IDS}. See rule 3 above.
161
+ */
162
+ export const ACCOUNT_CATEGORY_IDS = [
163
+ // ---- media & public information -----------------------------------------
164
+ 'news',
165
+ 'politics',
166
+ // ---- business & economy --------------------------------------------------
167
+ 'business',
168
+ 'startup',
169
+ 'finance',
170
+ 'crypto',
171
+ 'marketplace',
172
+ 'retail',
173
+ // The four ids the single-valued `organizationCategory` field used to hold,
174
+ // carried forward VERBATIM. Their labels may be rewritten freely; their ids
175
+ // may not, because live rows hold them.
176
+ 'real_estate',
177
+ 'agency',
178
+ 'landlord',
179
+ 'cooperative',
180
+ 'architecture',
181
+ // ---- technology ----------------------------------------------------------
182
+ 'technology',
183
+ 'software',
184
+ 'ai',
185
+ 'security',
186
+ 'automation',
187
+ // ---- knowledge -----------------------------------------------------------
188
+ 'science',
189
+ 'education',
190
+ 'books',
191
+ // ---- health --------------------------------------------------------------
192
+ 'health',
193
+ 'fitness',
194
+ // ---- sport & play --------------------------------------------------------
195
+ 'sports',
196
+ 'gaming',
197
+ // ---- culture & entertainment ---------------------------------------------
198
+ //
199
+ // There is deliberately NO generic `entertainment` here, and re-adding one is
200
+ // a regression rather than a gap. It is the only id this list ever carried
201
+ // that was dominated by its own specifics — `film`, `music`, `gaming` and
202
+ // `comedy` all exist — and a generic drawer sitting beside its four
203
+ // concretions collects the lazy pick, which degrades the data for all four at
204
+ // once: the accounts that would have said `film` say `entertainment` instead,
205
+ // and `film` stops meaning what it meant.
206
+ //
207
+ // The other overlaps in this vocabulary are NOT the same case and must not be
208
+ // merged on this reasoning: `sports`/`fitness`, `art`/`photography`,
209
+ // `business`/`startup`, `finance`/`crypto`, `home_garden`/`diy` and
210
+ // `technology`/`software`/`security` are genuinely different audiences, and
211
+ // with a cap of four the granularity is cheap.
212
+ 'music',
213
+ 'film',
214
+ 'podcast',
215
+ 'art',
216
+ 'photography',
217
+ 'comedy',
218
+ // ---- everyday life -------------------------------------------------------
219
+ 'food',
220
+ 'travel',
221
+ 'fashion',
222
+ 'home_garden',
223
+ 'diy',
224
+ 'automotive',
225
+ 'animals',
226
+ 'family',
227
+ // ---- society -------------------------------------------------------------
228
+ 'nonprofit',
229
+ 'government',
230
+ 'community',
231
+ 'activism',
232
+ 'environment',
233
+ 'religion',
234
+ // ---- fallback ------------------------------------------------------------
235
+ 'other',
236
+ ];
237
+ /**
238
+ * Ids withdrawn from the picker. Empty today.
239
+ *
240
+ * A withdrawn id keeps working everywhere it is already stored: it validates,
241
+ * it survives a round-trip save, it still renders from its label key, and it
242
+ * stays PRIMARY if it was primary. Nothing rewrites a stored list — a read-time
243
+ * or migration-time demotion would silently replace a choice its owner made,
244
+ * which is precisely what stable ids exist to prevent. The owner drops it on
245
+ * their next edit; until then it is honoured.
246
+ *
247
+ * What withdrawal changes is only this: the id leaves
248
+ * {@link SELECTABLE_ACCOUNT_CATEGORY_IDS}, so no picker offers it, and
249
+ * {@link newlyAddedRetiredCategories} refuses to let a write ADD it to an
250
+ * account that did not already have it.
251
+ */
252
+ export const RETIRED_ACCOUNT_CATEGORY_IDS = [];
253
+ /** Whether a category may still be OFFERED. A stored one is readable either way. */
254
+ export function isSelectableAccountCategoryId(id) {
255
+ return !RETIRED_ACCOUNT_CATEGORY_IDS.includes(id);
256
+ }
257
+ /** The ids a picker may offer, in declaration order. */
258
+ export const SELECTABLE_ACCOUNT_CATEGORY_IDS = ACCOUNT_CATEGORY_IDS.filter(isSelectableAccountCategoryId);
259
+ /**
260
+ * Which of `next` are withdrawn ids the account did not already carry — i.e.
261
+ * the ones a write must be refused for.
262
+ *
263
+ * `retired` is a parameter rather than a module read so the rule can be
264
+ * exercised against a non-empty set while the production one is empty; a test
265
+ * over `RETIRED_ACCOUNT_CATEGORY_IDS` alone would pass vacuously today and stay
266
+ * passing if the rule were deleted.
267
+ */
268
+ export function newlyAddedRetiredCategories(next, previous, retired) {
269
+ return next.filter((id) => retired.includes(id) && !previous.includes(id));
270
+ }
271
+ /**
272
+ * How many categories one account may carry.
273
+ *
274
+ * Four, not "as many as you like". Three reasons, in the order they bind:
275
+ *
276
+ * - The primary has to MEAN something. At ten categories the first element
277
+ * reads as a sort artifact rather than a choice, and rule 2 above is the
278
+ * entire mechanism by which a primary exists.
279
+ * - The profile RENDERS them as a row of chips; four labels of this length is
280
+ * what fits a phone-width profile header before the row wraps or truncates.
281
+ * - Four is enough to place a genuinely compound account without a tag cloud:
282
+ * a housing cooperative that is also a non-profit serving a local community
283
+ * spends `cooperative`, `nonprofit`, `community`, `real_estate` — and is the
284
+ * most compound real example in the ecosystem.
285
+ *
286
+ * One constant, read by the wire schema, the database CHECK and the picker, so
287
+ * changing it is one edit plus a migration.
288
+ */
289
+ export const MAX_ACCOUNT_CATEGORIES = 4;
290
+ /**
291
+ * Kinds that may carry categories: every kind EXCEPT `personal`.
292
+ *
293
+ * A person has interests, not a sector — and their interests are not a
294
+ * classification anybody else gets to read off their profile. Spelled out
295
+ * positively, like {@link isDelegatedActAsEligibleKind} and for the same reason: a `kind
296
+ * !== 'personal'` test silently admits every kind invented after it was
297
+ * written, whereas this list forces whoever adds one to decide.
298
+ */
299
+ export const ACCOUNT_CATEGORY_KINDS = [
300
+ 'organization',
301
+ 'project',
302
+ 'bot',
303
+ 'channel',
304
+ ];
305
+ /**
306
+ * Whether an account of this kind may carry categories.
307
+ *
308
+ * The API refuses the write and the `users_account_categories_kind_check`
309
+ * constraint makes it unrepresentable; both derive from
310
+ * {@link ACCOUNT_CATEGORY_KINDS}, so they cannot disagree.
311
+ */
312
+ export function kindAcceptsAccountCategories(kind) {
313
+ return ACCOUNT_CATEGORY_KINDS.includes(kind ?? '');
314
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Agent-key constants and checks, without Zod. Schemas: `../agentKeys.ts`.
3
+ *
4
+ * Part of `@oxy.so/contracts/client`: never import `zod` here (see `./check.ts`).
5
+ */
6
+ import { array, boolean, datetime, discriminatedUnion, exact, int, literal, maxLength, minLength, nullable, number, oneOf, optional, pattern, positive, strictObject, string, trim, } from './check.js';
7
+ import { reauthProofCheck } from './signIn.js';
8
+ export const AGENT_PROOF_AUDIENCE = 'oxy-api/agent';
9
+ export const AGENT_PROOF_TTL_MS = 60000;
10
+ export const AGENT_PROOF_ACTIONS = [
11
+ 'agent_signin',
12
+ 'agent_enroll',
13
+ 'agent_rotate',
14
+ 'agent_recover',
15
+ 'agent_governance',
16
+ ];
17
+ export const ACCOUNT_AUTH_METHODS = ['identity', 'agent_key'];
18
+ /* -------------------------------------------------------------------------- */
19
+ /* Checks */
20
+ /* -------------------------------------------------------------------------- */
21
+ const hex64 = string(pattern(/^[0-9a-f]{64}$/));
22
+ const hexSignature = string(pattern(/^[0-9a-f]+$/i), maxLength(160));
23
+ const timestamp = number(int, positive);
24
+ const publicKey = string(trim, minLength(1), maxLength(130));
25
+ const label = string(trim, minLength(1), maxLength(120));
26
+ export const agentProofClaimsCheck = exact()(strictObject({
27
+ version: literal(1),
28
+ audience: literal(AGENT_PROOF_AUDIENCE),
29
+ action: oneOf(AGENT_PROOF_ACTIONS),
30
+ accountId: string(minLength(1)),
31
+ actorId: string(minLength(1)),
32
+ authMethodId: nullable(string()),
33
+ publicKey: string(minLength(1)),
34
+ payloadDigest: hex64,
35
+ challenge: hex64,
36
+ expiresAt: number(int, positive),
37
+ }));
38
+ export const agentChallengeRequestCheck = exact()(strictObject({ publicKey }));
39
+ export const agentVerifyRequestCheck = exact()(strictObject({ challenge: hex64, signature: hexSignature, timestamp, publicKey }));
40
+ export const agentKeyOperationCheck = exact()(discriminatedUnion('operation', {
41
+ enroll: strictObject({ operation: literal('enroll'), publicKey, label }),
42
+ recover: strictObject({ operation: literal('recover'), publicKey, label }),
43
+ rotate: strictObject({
44
+ operation: literal('rotate'),
45
+ publicKey,
46
+ label,
47
+ retireCurrent: optional(boolean),
48
+ }),
49
+ revoke: strictObject({ operation: literal('revoke'), methodId: string(minLength(1)) }),
50
+ }));
51
+ export const executeAgentKeyOperationCheck = exact()(strictObject({
52
+ operation: agentKeyOperationCheck,
53
+ proof: strictObject({
54
+ challenge: hex64,
55
+ timestamp,
56
+ keySignature: optional(hexSignature),
57
+ governorSignature: optional(hexSignature),
58
+ reauth: optional(reauthProofCheck),
59
+ }),
60
+ }));
61
+ export const agentKeyOperationChallengeCheck = exact()(strictObject({ claims: agentProofClaimsCheck, governorPublicKey: nullable(string()) }));
62
+ export const agentKeyOperationResultCheck = exact()(strictObject({ methodId: nullable(string()), revokedMethodIds: array(string()) }));
63
+ export const agentKeyListResponseCheck = exact()(strictObject({
64
+ keys: array(strictObject({
65
+ id: string(),
66
+ publicKey: string(),
67
+ label: string(),
68
+ enrolledByUserId: nullable(string()),
69
+ enrollmentMethod: oneOf(['governor', 'rotation', 'recovery']),
70
+ linkedAt: string(datetime),
71
+ lastUsedAt: nullable(string(datetime)),
72
+ revokedAt: nullable(string(datetime)),
73
+ })),
74
+ }));