@gjermundgaraba/clankerauth-admin-api 0.0.0-stage → 0.25.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/dist/index.js ADDED
@@ -0,0 +1,359 @@
1
+ import { Context, Schema } from "effect";
2
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
3
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
4
+ import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
5
+ import { HttpApiSchema, HttpApiSecurity } from "effect/http-api";
6
+ /**
7
+ * This issuer's own fault: a failed query, an unreadable row, an unexpected provider failure.
8
+ * The schema defines the public response; the diagnostic cause reaches logs, never a caller.
9
+ */
10
+ export class InternalServerError extends Schema.TaggedError()("InternalServerError", { message: Schema.String }, { httpApiStatus: 500 }) {
11
+ cause;
12
+ static of = (cause) => {
13
+ const error = new InternalServerError({ message: "Request could not be completed" });
14
+ error.cause = cause;
15
+ return error;
16
+ };
17
+ }
18
+ /** The issuer could not decide the request now, such as when its session store is unreachable. */
19
+ export class ServiceUnavailable extends Schema.TaggedError()("ServiceUnavailable", { message: Schema.String }, { httpApiStatus: 503 }) {
20
+ }
21
+ export class ClientNotFound extends Schema.TaggedError()("ClientNotFound", { clientId: Schema.String, message: Schema.String }, { httpApiStatus: 404 }) {
22
+ static of = (clientId) => new ClientNotFound({ clientId, message: `No client ${clientId} is registered` });
23
+ }
24
+ export class ResourceNotFound extends Schema.TaggedError()("ResourceNotFound", { identifier: Schema.String, message: Schema.String }, { httpApiStatus: 404 }) {
25
+ static of = (identifier) => new ResourceNotFound({ identifier, message: `No resource ${identifier} exists` });
26
+ }
27
+ export class ApiKeyNotFound extends Schema.TaggedError()("ApiKeyNotFound", { keyId: Schema.String, message: Schema.String }, { httpApiStatus: 404 }) {
28
+ static of = (keyId) => new ApiKeyNotFound({ keyId, message: `No API key ${keyId} exists` });
29
+ }
30
+ /**
31
+ * The client registered itself, through DCR or CIMD: the owner can revoke, block or delete it,
32
+ * but its access and registration are its own, and consent decides what it gets.
33
+ */
34
+ export class ClientIsAutomatic extends Schema.TaggedError()("ClientIsAutomatic", { clientId: Schema.String, message: Schema.String }, { httpApiStatus: 409 }) {
35
+ static of = (clientId) => new ClientIsAutomatic({
36
+ clientId,
37
+ message: `Client ${clientId} registered itself; revoke, block or delete it instead`,
38
+ });
39
+ }
40
+ /** First-run setup has already created the owner. */
41
+ export class OwnerExists extends Schema.TaggedError()("OwnerExists", { message: Schema.String }, { httpApiStatus: 409 }) {
42
+ static of = () => new OwnerExists({ message: "Setup already completed" });
43
+ }
44
+ /** The provider refused the request's input on a rule of its own, which `message` states. */
45
+ export class ProviderRefused extends Schema.TaggedError()("ProviderRefused", { message: Schema.String }, { httpApiStatus: 400 }) {
46
+ }
47
+ /** A request body the issuer could not read, on a route that is not an action. */
48
+ export class UnreadableRequest extends Schema.TaggedError()("UnreadableRequest", { message: Schema.String }, { httpApiStatus: 400 }) {
49
+ }
50
+ const undecided = [ServiceUnavailable, InternalServerError];
51
+ export class CurrentOwner extends Context.Service()("clankerauth/CurrentOwner") {
52
+ }
53
+ // why: the cookie name varies by deployment, and a layer serves only its own descriptor's binding.
54
+ export const ownerSession = (cookie) => Authentication.make("clankerauth.OwnerSession", CurrentOwner, {
55
+ security: HttpApiSecurity.apiKey({ in: "cookie", key: cookie }),
56
+ error: undecided,
57
+ });
58
+ // why: a browser sends its cookies itself, so only the server would read this cookie name.
59
+ export const OwnerSession = ownerSession("better-auth.session_token");
60
+ export const OwnerToken = Authentication.make("clankerauth.OwnerToken", CurrentOwner, {
61
+ error: undecided,
62
+ });
63
+ export const administrationIdentifier = (origin) => new URL("/", origin).href;
64
+ export const administrationScopes = { read: "clankerauth:read", write: "clankerauth:write" };
65
+ export const MINIMUM_PASSWORD_LENGTH = 8;
66
+ export const MAXIMUM_PASSWORD_LENGTH = 128;
67
+ export const passwordRule = `Passwords have ${MINIMUM_PASSWORD_LENGTH} to ${MAXIMUM_PASSWORD_LENGTH} characters`;
68
+ export const SetupInput = Schema.Struct({
69
+ email: Schema.String.check(Schema.isPattern(/\S/u, { message: "Setup requires an email" })),
70
+ password: Schema.String.check(Schema.isMinLength(MINIMUM_PASSWORD_LENGTH, { message: passwordRule }), Schema.isMaxLength(MAXIMUM_PASSWORD_LENGTH, { message: passwordRule })),
71
+ });
72
+ export const ClientAuthMethod = Schema.Literals([
73
+ "none",
74
+ "client_secret_basic",
75
+ "client_secret_post",
76
+ ]);
77
+ export const ApplicationType = Schema.Literals(["web", "native"]);
78
+ /** The resources a managed client may obtain, each named once. */
79
+ const ResourceSelection = Schema.Array(Schema.String).check(Schema.isUnique({ message: "Choose unique Resources" }));
80
+ /** The provider's registration fields, passed through unchanged, and the client's `resources`. */
81
+ export const ClientInput = Schema.Struct({
82
+ client_name: Schema.String,
83
+ redirect_uris: Schema.Array(Schema.String),
84
+ resources: ResourceSelection,
85
+ token_endpoint_auth_method: ClientAuthMethod,
86
+ application_type: ApplicationType,
87
+ });
88
+ // why: the provider's update endpoint cannot change `token_endpoint_auth_method`.
89
+ export const ClientUpdateInput = Schema.Struct({
90
+ client_id: Schema.String,
91
+ client_name: Schema.String,
92
+ redirect_uris: Schema.Array(Schema.String),
93
+ application_type: ApplicationType,
94
+ });
95
+ export const ClientId = Schema.Struct({ client_id: Schema.String });
96
+ /** Without a resource, every authorization the client holds. */
97
+ export const ClientRevokeInput = Schema.Struct({
98
+ client_id: Schema.String,
99
+ resource: Schema.optional(Schema.String),
100
+ });
101
+ export const ClientBlockInput = Schema.Struct({
102
+ client_id: Schema.String,
103
+ blocked: Schema.Boolean,
104
+ });
105
+ export const Client = Schema.Struct({
106
+ onboarding: Schema.optional(Schema.Literals(["managed", "dcr", "cimd"])),
107
+ blocked: Schema.optional(Schema.Boolean),
108
+ client_id: Schema.String,
109
+ client_name: Schema.optional(Schema.String),
110
+ redirect_uris: Schema.Array(Schema.String),
111
+ token_endpoint_auth_method: Schema.optional(Schema.String),
112
+ application_type: Schema.optional(Schema.NullOr(Schema.String)),
113
+ scope: Schema.optional(Schema.String),
114
+ grant_types: Schema.optional(Schema.Array(Schema.String)),
115
+ });
116
+ export const ClientCredentials = Client.pipe(Schema.fieldsAssign({
117
+ client_secret: Schema.optional(Schema.String),
118
+ client_secret_expires_at: Schema.optional(Schema.Number),
119
+ }));
120
+ export const Resource = Schema.Struct({
121
+ identifier: Schema.String,
122
+ name: Schema.String,
123
+ scopes: Schema.Array(Schema.String),
124
+ });
125
+ export const ResourceSummary = Resource.pipe(Schema.fieldsAssign({ builtIn: Schema.Boolean }));
126
+ // why: the issuer trims each field before storing it, so these checks allow surrounding spaces.
127
+ export const ResourceInput = Schema.Struct({
128
+ identifier: Schema.String,
129
+ name: Schema.String.check(Schema.isPattern(/\S/u, { message: "Resources require a name" })),
130
+ scopes: Schema.Array(Schema.String.check(Schema.isPattern(/^\s*[\x21\x23-\x5B\x5D-\x7E]+\s*$/u, {
131
+ message: "Resources require well-formed custom scopes",
132
+ }))),
133
+ });
134
+ export const ResourceId = Schema.Struct({ identifier: Schema.String });
135
+ /** What `putResource` stored, and whether it created the resource rather than updating it. */
136
+ export const PutResourceResult = Schema.Struct({ resource: Resource, created: Schema.Boolean });
137
+ export const ClientAccess = Schema.Struct({
138
+ client_id: Schema.String,
139
+ resource: Schema.String,
140
+ });
141
+ export const ClientAccessInput = Schema.Struct({
142
+ client_id: Schema.String,
143
+ resources: ResourceSelection,
144
+ });
145
+ export const ClientAccessResult = Schema.Struct({ clientAccess: Schema.Array(ClientAccess) });
146
+ export const KeyPermissions = Schema.Record(Schema.String, Schema.Array(Schema.String));
147
+ /**
148
+ * Permissions as the owner grants them: at least one resource, each with scopes named once.
149
+ * A stored key's are `KeyPermissions`, which a key whose permissions no longer decode is
150
+ * listed with, empty.
151
+ */
152
+ const KeyGrants = Schema.Record(Schema.String, Schema.Array(Schema.String).check(Schema.isMinLength(1, {
153
+ message: "Select explicitly granted, currently available Resource scopes",
154
+ }), Schema.isUnique({ message: "Select explicitly granted, currently available Resource scopes" }))).check(Schema.isMinProperties(1, { message: "Select at least one Resource and scope" }));
155
+ /** A key's name as the owner writes it; the issuer stores it trimmed. */
156
+ const KeyName = Schema.String.check(Schema.isMaxLength(100, { message: "Key names require 1–100 characters" }), Schema.isPattern(/\S/u, { message: "Key names require 1–100 characters" }));
157
+ // why: an input without an offset reads as UTC, so no instant depends on the host's time zone.
158
+ const Timestamp = Schema.DateTimeUtcFromString;
159
+ /**
160
+ * What a client holds for one resource: a consent, a live refresh token, or both. A
161
+ * managed client skips consent, so `approvedAt` is null for it and it shows only while it
162
+ * holds a refresh token. `scopes` are the consent's, else the newest token's.
163
+ * `refreshedAt` is when the newest live refresh token was issued, at sign-in or on refresh.
164
+ */
165
+ export const Connection = Schema.Struct({
166
+ client_id: Schema.String,
167
+ resource: Schema.String,
168
+ scopes: Schema.Array(Schema.String),
169
+ approvedAt: Schema.NullOr(Timestamp),
170
+ refreshedAt: Schema.NullOr(Timestamp),
171
+ });
172
+ export const ApiKeyInput = Schema.Struct({
173
+ name: KeyName,
174
+ permissions: KeyGrants,
175
+ expiresAt: Schema.NullOr(Timestamp),
176
+ });
177
+ export const ApiKeyId = Schema.Struct({ keyId: Schema.String });
178
+ export const ApiKeyUpdate = Schema.Struct({
179
+ keyId: Schema.String,
180
+ name: Schema.optional(KeyName),
181
+ permissions: Schema.optional(KeyGrants),
182
+ enabled: Schema.optional(Schema.Boolean),
183
+ });
184
+ export const ApiKey = Schema.Struct({
185
+ keyId: Schema.String,
186
+ name: Schema.String,
187
+ enabled: Schema.Boolean,
188
+ permissions: KeyPermissions,
189
+ expiresAt: Schema.NullOr(Timestamp),
190
+ createdAt: Timestamp,
191
+ });
192
+ const anyone = { caller: Action.Anyone };
193
+ const owned = { caller: CurrentOwner };
194
+ export const IssuerActions = [
195
+ Action.make("setupStatus", {
196
+ description: "Check whether the issuer needs its first owner account.",
197
+ readOnly: true,
198
+ ...anyone,
199
+ error: [InternalServerError],
200
+ success: Schema.Struct({ required: Schema.Boolean }),
201
+ }),
202
+ Action.make("setupOwner", {
203
+ description: "Create the first owner account.",
204
+ readOnly: false,
205
+ ...anyone,
206
+ error: [OwnerExists, ProviderRefused, InternalServerError],
207
+ input: SetupInput,
208
+ success: Schema.Struct({ created: Schema.Boolean }).pipe(HttpApiSchema.status(201)),
209
+ }),
210
+ Action.make("keyList", {
211
+ description: "The signed list a resource server verifies API keys against: one sealed entry per key granted on the resource, readable only with that key.",
212
+ readOnly: true,
213
+ ...anyone,
214
+ error: [InternalServerError],
215
+ input: Schema.Struct({ resource: Schema.String }),
216
+ success: Schema.Struct({ list: Schema.String }),
217
+ }),
218
+ ];
219
+ export const ClientListResult = Schema.Struct({
220
+ clients: Schema.Array(Client),
221
+ resources: Schema.Array(ResourceSummary),
222
+ clientAccess: Schema.Array(ClientAccess),
223
+ connections: Schema.Array(Connection),
224
+ email: Schema.String,
225
+ issuer: Schema.String,
226
+ });
227
+ export const Administration = [
228
+ Action.make("listClients", {
229
+ description: "List clients, resources, managed clients' resource access (what they may obtain), and every client's connections (what it holds).",
230
+ readOnly: true,
231
+ ...owned,
232
+ error: [InternalServerError],
233
+ success: ClientListResult,
234
+ }),
235
+ Action.make("createClient", {
236
+ description: "Register a first-party OAuth client. Returns its secret once when confidential.",
237
+ readOnly: false,
238
+ ...owned,
239
+ error: [ProviderRefused, InternalServerError],
240
+ input: ClientInput,
241
+ success: ClientCredentials.pipe(HttpApiSchema.status(201)),
242
+ }),
243
+ Action.make("updateClient", {
244
+ description: "Rename a first-party client or change its redirect URIs and application type.",
245
+ readOnly: false,
246
+ ...owned,
247
+ error: [ClientNotFound, ClientIsAutomatic, ProviderRefused, InternalServerError],
248
+ input: ClientUpdateInput,
249
+ success: Client,
250
+ }),
251
+ Action.make("deleteClient", {
252
+ description: "Delete a client and its stored authorization. An automatic client can register again; block it to keep it out.",
253
+ readOnly: false,
254
+ ...owned,
255
+ error: [ClientNotFound, InternalServerError],
256
+ input: ClientId,
257
+ success: Schema.Struct({ deleted: Schema.Boolean }),
258
+ }),
259
+ Action.make("revokeClient", {
260
+ description: "Revoke a client’s stored authorization, for one resource or all. It must authorize again; an automatic client asks for consent.",
261
+ readOnly: false,
262
+ ...owned,
263
+ error: [ClientNotFound, InternalServerError],
264
+ input: ClientRevokeInput,
265
+ success: Schema.Struct({ revoked: Schema.Boolean }),
266
+ }),
267
+ Action.make("blockClient", {
268
+ description: "Block or unblock OAuth authorization for a client.",
269
+ readOnly: false,
270
+ ...owned,
271
+ error: [ClientNotFound, InternalServerError],
272
+ input: ClientBlockInput,
273
+ success: Schema.Struct({ blocked: Schema.Boolean }),
274
+ }),
275
+ Action.make("rotateClientSecret", {
276
+ description: "Rotate a confidential client secret. Returns the new secret once.",
277
+ readOnly: false,
278
+ ...owned,
279
+ error: [ClientNotFound, ClientIsAutomatic, ProviderRefused, InternalServerError],
280
+ input: ClientId,
281
+ success: ClientCredentials,
282
+ }),
283
+ Action.make("setClientAccess", {
284
+ description: "Set the resources a managed client may obtain. Removing one revokes its authorization for it. Automatic clients may ask for any resource with consent.",
285
+ readOnly: false,
286
+ ...owned,
287
+ error: [ClientNotFound, ClientIsAutomatic, InternalServerError],
288
+ input: ClientAccessInput,
289
+ success: ClientAccessResult,
290
+ }),
291
+ Action.make("getResource", {
292
+ description: "Get one resource and its scopes by identifier.",
293
+ readOnly: true,
294
+ ...owned,
295
+ error: [ResourceNotFound, InternalServerError],
296
+ input: ResourceId,
297
+ success: ResourceSummary,
298
+ }),
299
+ Action.make("putResource", {
300
+ description: "Create an OAuth resource, or update the one with its identifier, to exactly these name and scopes.",
301
+ readOnly: false,
302
+ ...owned,
303
+ error: [ProviderRefused, InternalServerError],
304
+ input: ResourceInput,
305
+ success: PutResourceResult,
306
+ }),
307
+ Action.make("deleteResource", {
308
+ description: "Delete a resource, its client access and every client’s authorization for it. API-key permissions are retained; recreating the resource restores them.",
309
+ readOnly: false,
310
+ ...owned,
311
+ error: [ResourceNotFound, InternalServerError],
312
+ input: ResourceId,
313
+ success: Schema.Struct({ deleted: Schema.Boolean }),
314
+ }),
315
+ Action.make("listApiKeys", {
316
+ description: "List API key metadata without secret values.",
317
+ readOnly: true,
318
+ ...owned,
319
+ error: [InternalServerError],
320
+ success: Schema.Struct({ keys: Schema.Array(ApiKey) }),
321
+ }),
322
+ Action.make("createApiKey", {
323
+ description: "Create a scoped API key. Returns its secret once.",
324
+ readOnly: false,
325
+ ...owned,
326
+ error: [ProviderRefused, InternalServerError],
327
+ input: ApiKeyInput,
328
+ success: ApiKey.pipe(Schema.fieldsAssign({ key: Schema.String }), HttpApiSchema.status(201)),
329
+ }),
330
+ Action.make("updateApiKey", {
331
+ description: "Update an API key’s name, permissions or enabled state.",
332
+ readOnly: false,
333
+ ...owned,
334
+ error: [ApiKeyNotFound, ProviderRefused, InternalServerError],
335
+ input: ApiKeyUpdate,
336
+ success: ApiKey,
337
+ }),
338
+ Action.make("deleteApiKey", {
339
+ description: "Delete an API key.",
340
+ readOnly: false,
341
+ ...owned,
342
+ error: [ApiKeyNotFound, InternalServerError],
343
+ input: ApiKeyId,
344
+ success: Schema.Struct({ deleted: Schema.Boolean }),
345
+ }),
346
+ ];
347
+ export const errors = [
348
+ ...new Set([
349
+ ...[...IssuerActions, ...Administration].flatMap((action) => action.error),
350
+ ...undecided,
351
+ UnreadableRequest,
352
+ ]),
353
+ ];
354
+ export const binding = (authentication) => ActionHttp.make([...Administration, ...IssuerActions], { authentication });
355
+ export const Http = binding(OwnerSession);
356
+ export const OwnerHttp = ActionHttp.make(Administration, {
357
+ prefix: "/api/owner",
358
+ authentication: OwnerToken,
359
+ });
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerauth-admin-api",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.25.0",
4
+ "description": "The clankerauth administration contract: its effect-actions actions, schemas, errors and HTTP bindings, for administering an issuer from a program.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/gjermundgaraba/clankerauth.git",
9
+ "directory": "packages/admin-api"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ }
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "devDependencies": {
26
+ "@gjermundgaraba/effect-actions": "0.18.2",
27
+ "effect": "4.0.5",
28
+ "typescript": "7.0.2"
29
+ },
30
+ "peerDependencies": {
31
+ "@gjermundgaraba/effect-actions": "^0.18.0",
32
+ "effect": "~4.0.4"
33
+ },
34
+ "engines": {
35
+ "node": ">=26"
36
+ },
37
+ "scripts": {
38
+ "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
39
+ "test": "node scripts/package-smoke.mjs"
40
+ }
6
41
  }