@gjermundgaraba/clankerauth 0.22.0 → 0.23.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/main.mjs +155 -183
- package/package.json +5 -5
package/dist/main.mjs
CHANGED
|
@@ -15,67 +15,88 @@ import { dirname, join } from "node:path";
|
|
|
15
15
|
import { createServer } from "node:http";
|
|
16
16
|
import * as oauth from "oauth4webapi";
|
|
17
17
|
//#region ../../packages/admin-api/dist/index.js
|
|
18
|
-
var BadRequest = class extends Schema.TaggedError()("BadRequest", { error: Schema.String }, { httpApiStatus: 400 }) {};
|
|
19
|
-
var NotFound = class extends Schema.TaggedError()("NotFound", { error: Schema.String }, { httpApiStatus: 404 }) {};
|
|
20
|
-
var Conflict = class extends Schema.TaggedError()("Conflict", { error: Schema.String }, { httpApiStatus: 409 }) {};
|
|
21
|
-
var TooManyRequests = class extends Schema.TaggedError()("TooManyRequests", { error: Schema.String }, { httpApiStatus: 429 }) {};
|
|
22
|
-
/** The schema defines the public response; the diagnostic cause reaches logs, never a caller. */
|
|
23
|
-
var InternalServerError = class extends Schema.TaggedError()("InternalServerError", { error: Schema.String }, { httpApiStatus: 500 }) {
|
|
24
|
-
cause;
|
|
25
|
-
};
|
|
26
|
-
var ServiceUnavailable = class extends Schema.TaggedError()("ServiceUnavailable", { error: Schema.String }, { httpApiStatus: 503 }) {};
|
|
27
18
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
19
|
+
* This issuer's own fault: a failed query, an unreadable row, an unexpected provider failure.
|
|
20
|
+
* The schema defines the public response; the diagnostic cause reaches logs, never a caller.
|
|
30
21
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
22
|
+
var InternalServerError = class InternalServerError extends Schema.TaggedError()("InternalServerError", { message: Schema.String }, { httpApiStatus: 500 }) {
|
|
23
|
+
cause;
|
|
24
|
+
static of = (cause) => {
|
|
25
|
+
const error = new InternalServerError({ message: "Request could not be completed" });
|
|
26
|
+
error.cause = cause;
|
|
27
|
+
return error;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
/** The issuer could not decide the request now, such as when its session store is unreachable. */
|
|
31
|
+
var ServiceUnavailable = class extends Schema.TaggedError()("ServiceUnavailable", { message: Schema.String }, { httpApiStatus: 503 }) {};
|
|
32
|
+
var ClientNotFound = class ClientNotFound extends Schema.TaggedError()("ClientNotFound", {
|
|
33
|
+
clientId: Schema.String,
|
|
34
|
+
message: Schema.String
|
|
35
|
+
}, { httpApiStatus: 404 }) {
|
|
36
|
+
static of = (clientId) => new ClientNotFound({
|
|
37
|
+
clientId,
|
|
38
|
+
message: `No client ${clientId} is registered`
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
var ResourceNotFound = class ResourceNotFound extends Schema.TaggedError()("ResourceNotFound", {
|
|
42
|
+
identifier: Schema.String,
|
|
43
|
+
message: Schema.String
|
|
44
|
+
}, { httpApiStatus: 404 }) {
|
|
45
|
+
static of = (identifier) => new ResourceNotFound({
|
|
46
|
+
identifier,
|
|
47
|
+
message: `No resource ${identifier} exists`
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
var ApiKeyNotFound = class ApiKeyNotFound extends Schema.TaggedError()("ApiKeyNotFound", {
|
|
51
|
+
keyId: Schema.String,
|
|
52
|
+
message: Schema.String
|
|
53
|
+
}, { httpApiStatus: 404 }) {
|
|
54
|
+
static of = (keyId) => new ApiKeyNotFound({
|
|
55
|
+
keyId,
|
|
56
|
+
message: `No API key ${keyId} exists`
|
|
57
|
+
});
|
|
58
|
+
};
|
|
40
59
|
/**
|
|
41
|
-
* The
|
|
42
|
-
*
|
|
43
|
-
* at startup.
|
|
60
|
+
* The client registered itself, through DCR or CIMD: the owner can revoke, block or delete it,
|
|
61
|
+
* but its access and registration are its own, and consent decides what it gets.
|
|
44
62
|
*/
|
|
63
|
+
var ClientIsAutomatic = class ClientIsAutomatic extends Schema.TaggedError()("ClientIsAutomatic", {
|
|
64
|
+
clientId: Schema.String,
|
|
65
|
+
message: Schema.String
|
|
66
|
+
}, { httpApiStatus: 409 }) {
|
|
67
|
+
static of = (clientId) => new ClientIsAutomatic({
|
|
68
|
+
clientId,
|
|
69
|
+
message: `Client ${clientId} registered itself; revoke, block or delete it instead`
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
/** First-run setup has already created the owner. */
|
|
73
|
+
var OwnerExists = class OwnerExists extends Schema.TaggedError()("OwnerExists", { message: Schema.String }, { httpApiStatus: 409 }) {
|
|
74
|
+
static of = () => new OwnerExists({ message: "Setup already completed" });
|
|
75
|
+
};
|
|
76
|
+
/** The provider refused the request's input on a rule of its own, which `message` states. */
|
|
77
|
+
var ProviderRefused = class extends Schema.TaggedError()("ProviderRefused", { message: Schema.String }, { httpApiStatus: 400 }) {};
|
|
78
|
+
/** A request body the issuer could not read, on a route that is not an action. */
|
|
79
|
+
var UnreadableRequest = class extends Schema.TaggedError()("UnreadableRequest", { message: Schema.String }, { httpApiStatus: 400 }) {};
|
|
80
|
+
const undecided = [ServiceUnavailable, InternalServerError];
|
|
45
81
|
var CurrentOwner = class extends Context.Service()("clankerauth/CurrentOwner") {};
|
|
46
|
-
/**
|
|
47
|
-
* The dashboard's owner session: the issuer's session cookie, named `cookie`, which the browser
|
|
48
|
-
* sends. The provider names that cookie by the deployment, `__Secure-` prefixed over HTTPS, so
|
|
49
|
-
* the server builds this descriptor where it starts, from the name its sessions use, and builds
|
|
50
|
-
* both its binding and its `Authentication.layer` from it: that layer serves only a binding
|
|
51
|
-
* built from that very descriptor, not another naming the same cookie. A browser's binding,
|
|
52
|
-
* from `OwnerSession`, only calls. Its verifier reads the session through the provider, so it
|
|
53
|
-
* may fail as an action does, with any of `errors`, such as `ServiceUnavailable`, answered with
|
|
54
|
-
* its own status.
|
|
55
|
-
*/
|
|
56
82
|
const ownerSession = (cookie) => Authentication.make("clankerauth.OwnerSession", CurrentOwner, {
|
|
57
83
|
security: HttpApiSecurity.apiKey({
|
|
58
84
|
in: "cookie",
|
|
59
85
|
key: cookie
|
|
60
86
|
}),
|
|
61
|
-
error:
|
|
87
|
+
error: undecided
|
|
62
88
|
});
|
|
63
|
-
/**
|
|
64
|
-
* The browser's owner session. A page never needs the cookie's name, as the browser sends its
|
|
65
|
-
* cookies itself: this one names the provider's cookie over plain HTTP, which only the server
|
|
66
|
-
* would read.
|
|
67
|
-
*/
|
|
68
89
|
const OwnerSession = ownerSession("better-auth.session_token");
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
90
|
+
const OwnerToken = Authentication.make("clankerauth.OwnerToken", CurrentOwner, { error: undecided });
|
|
91
|
+
const administrationIdentifier = (origin) => new URL("/", origin).href;
|
|
92
|
+
const administrationScopes = {
|
|
93
|
+
read: "clankerauth:read",
|
|
94
|
+
write: "clankerauth:write"
|
|
95
|
+
};
|
|
96
|
+
const passwordRule = `Passwords have 8 to 128 characters`;
|
|
76
97
|
const SetupInput = Schema.Struct({
|
|
77
|
-
email: Schema.String,
|
|
78
|
-
password: Schema.String
|
|
98
|
+
email: Schema.String.check(Schema.isPattern(/\S/u, { message: "Setup requires an email" })),
|
|
99
|
+
password: Schema.String.check(Schema.isMinLength(8, { message: passwordRule }), Schema.isMaxLength(128, { message: passwordRule }))
|
|
79
100
|
});
|
|
80
101
|
const ClientAuthMethod = Schema.Literals([
|
|
81
102
|
"none",
|
|
@@ -85,7 +106,7 @@ const ClientAuthMethod = Schema.Literals([
|
|
|
85
106
|
const ApplicationType = Schema.Literals(["web", "native"]);
|
|
86
107
|
/** The resources a managed client may obtain, each named once. */
|
|
87
108
|
const ResourceSelection = Schema.Array(Schema.String).check(Schema.isUnique({ message: "Choose unique Resources" }));
|
|
88
|
-
/**
|
|
109
|
+
/** The provider's registration fields, passed through unchanged, and the client's `resources`. */
|
|
89
110
|
const ClientInput = Schema.Struct({
|
|
90
111
|
client_name: Schema.String,
|
|
91
112
|
redirect_uris: Schema.Array(Schema.String),
|
|
@@ -93,7 +114,6 @@ const ClientInput = Schema.Struct({
|
|
|
93
114
|
token_endpoint_auth_method: ClientAuthMethod,
|
|
94
115
|
application_type: ApplicationType
|
|
95
116
|
});
|
|
96
|
-
/** The provider's update endpoint cannot change the authentication method. */
|
|
97
117
|
const ClientUpdateInput = Schema.Struct({
|
|
98
118
|
client_id: Schema.String,
|
|
99
119
|
client_name: Schema.String,
|
|
@@ -135,11 +155,6 @@ const Resource = Schema.Struct({
|
|
|
135
155
|
scopes: Schema.Array(Schema.String)
|
|
136
156
|
});
|
|
137
157
|
const ResourceSummary = Resource.pipe(Schema.fieldsAssign({ builtIn: Schema.Boolean }));
|
|
138
|
-
/**
|
|
139
|
-
* A resource as the owner writes it. The issuer trims each field before storing it, so the
|
|
140
|
-
* rules allow surrounding whitespace: a name that is not blank, and scopes that are OAuth
|
|
141
|
-
* scope tokens (RFC 6749 §3.3). Stored resources are `Resource`, which carries no rule.
|
|
142
|
-
*/
|
|
143
158
|
const ResourceInput = Schema.Struct({
|
|
144
159
|
identifier: Schema.String,
|
|
145
160
|
name: Schema.String.check(Schema.isPattern(/\S/u, { message: "Resources require a name" })),
|
|
@@ -164,11 +179,6 @@ const KeyPermissions = Schema.Record(Schema.String, Schema.Array(Schema.String))
|
|
|
164
179
|
const KeyGrants = Schema.Record(Schema.String, Schema.Array(Schema.String).check(Schema.isMinLength(1, { message: "Select explicitly granted, currently available Resource scopes" }), Schema.isUnique({ message: "Select explicitly granted, currently available Resource scopes" }))).check(Schema.isMinProperties(1, { message: "Select at least one Resource and scope" }));
|
|
165
180
|
/** A key's name as the owner writes it; the issuer stores it trimmed. */
|
|
166
181
|
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" }));
|
|
167
|
-
/**
|
|
168
|
-
* Timestamps stay ISO strings on the wire and arrive as `DateTime.Utc` values. An input
|
|
169
|
-
* without an offset is read as UTC, so the instant an API caller means never depends on
|
|
170
|
-
* the host's time zone; every value this issuer writes carries `Z`.
|
|
171
|
-
*/
|
|
172
182
|
const Timestamp = Schema.DateTimeUtcFromString;
|
|
173
183
|
/**
|
|
174
184
|
* What a client holds for one resource: a consent, a live refresh token, or both. A
|
|
@@ -195,7 +205,7 @@ const ApiKeyUpdate = Schema.Struct({
|
|
|
195
205
|
permissions: Schema.optional(KeyGrants),
|
|
196
206
|
enabled: Schema.optional(Schema.Boolean)
|
|
197
207
|
});
|
|
198
|
-
const
|
|
208
|
+
const ApiKey = Schema.Struct({
|
|
199
209
|
keyId: Schema.String,
|
|
200
210
|
name: Schema.String,
|
|
201
211
|
enabled: Schema.Boolean,
|
|
@@ -203,27 +213,25 @@ const MachineKey = Schema.Struct({
|
|
|
203
213
|
expiresAt: Schema.NullOr(Timestamp),
|
|
204
214
|
createdAt: Timestamp
|
|
205
215
|
});
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
caller: Action.Anyone,
|
|
209
|
-
error: errors
|
|
210
|
-
};
|
|
211
|
-
/** What every administration action shares: the owner as its caller, and the contract's errors. */
|
|
212
|
-
const owned = {
|
|
213
|
-
caller: CurrentOwner,
|
|
214
|
-
error: errors
|
|
215
|
-
};
|
|
216
|
+
const anyone = { caller: Action.Anyone };
|
|
217
|
+
const owned = { caller: CurrentOwner };
|
|
216
218
|
const IssuerActions = [
|
|
217
219
|
Action.make("setupStatus", {
|
|
218
220
|
description: "Check whether the issuer needs its first owner account.",
|
|
219
221
|
readOnly: true,
|
|
220
222
|
...anyone,
|
|
223
|
+
error: [InternalServerError],
|
|
221
224
|
success: Schema.Struct({ required: Schema.Boolean })
|
|
222
225
|
}),
|
|
223
226
|
Action.make("setupOwner", {
|
|
224
227
|
description: "Create the first owner account.",
|
|
225
228
|
readOnly: false,
|
|
226
229
|
...anyone,
|
|
230
|
+
error: [
|
|
231
|
+
OwnerExists,
|
|
232
|
+
ProviderRefused,
|
|
233
|
+
InternalServerError
|
|
234
|
+
],
|
|
227
235
|
input: SetupInput,
|
|
228
236
|
success: Schema.Struct({ created: Schema.Boolean }).pipe(HttpApiSchema.status(201))
|
|
229
237
|
}),
|
|
@@ -231,6 +239,7 @@ const IssuerActions = [
|
|
|
231
239
|
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.",
|
|
232
240
|
readOnly: true,
|
|
233
241
|
...anyone,
|
|
242
|
+
error: [InternalServerError],
|
|
234
243
|
input: Schema.Struct({ resource: Schema.String }),
|
|
235
244
|
success: Schema.Struct({ list: Schema.String })
|
|
236
245
|
})
|
|
@@ -248,12 +257,14 @@ const Administration = [
|
|
|
248
257
|
description: "List clients, resources, managed clients' resource access (what they may obtain), and every client's connections (what it holds).",
|
|
249
258
|
readOnly: true,
|
|
250
259
|
...owned,
|
|
260
|
+
error: [InternalServerError],
|
|
251
261
|
success: ClientListResult
|
|
252
262
|
}),
|
|
253
263
|
Action.make("createClient", {
|
|
254
264
|
description: "Register a first-party OAuth client. Returns its secret once when confidential.",
|
|
255
265
|
readOnly: false,
|
|
256
266
|
...owned,
|
|
267
|
+
error: [ProviderRefused, InternalServerError],
|
|
257
268
|
input: ClientInput,
|
|
258
269
|
success: ClientCredentials.pipe(HttpApiSchema.status(201))
|
|
259
270
|
}),
|
|
@@ -261,6 +272,12 @@ const Administration = [
|
|
|
261
272
|
description: "Rename a first-party client or change its redirect URIs and application type.",
|
|
262
273
|
readOnly: false,
|
|
263
274
|
...owned,
|
|
275
|
+
error: [
|
|
276
|
+
ClientNotFound,
|
|
277
|
+
ClientIsAutomatic,
|
|
278
|
+
ProviderRefused,
|
|
279
|
+
InternalServerError
|
|
280
|
+
],
|
|
264
281
|
input: ClientUpdateInput,
|
|
265
282
|
success: Client
|
|
266
283
|
}),
|
|
@@ -268,6 +285,7 @@ const Administration = [
|
|
|
268
285
|
description: "Delete a client and its stored authorization. An automatic client can register again; block it to keep it out.",
|
|
269
286
|
readOnly: false,
|
|
270
287
|
...owned,
|
|
288
|
+
error: [ClientNotFound, InternalServerError],
|
|
271
289
|
input: ClientId,
|
|
272
290
|
success: Schema.Struct({ deleted: Schema.Boolean })
|
|
273
291
|
}),
|
|
@@ -275,6 +293,7 @@ const Administration = [
|
|
|
275
293
|
description: "Revoke a client’s stored authorization, for one resource or all. It must authorize again; an automatic client asks for consent.",
|
|
276
294
|
readOnly: false,
|
|
277
295
|
...owned,
|
|
296
|
+
error: [ClientNotFound, InternalServerError],
|
|
278
297
|
input: ClientRevokeInput,
|
|
279
298
|
success: Schema.Struct({ revoked: Schema.Boolean })
|
|
280
299
|
}),
|
|
@@ -282,6 +301,7 @@ const Administration = [
|
|
|
282
301
|
description: "Block or unblock OAuth authorization for a client.",
|
|
283
302
|
readOnly: false,
|
|
284
303
|
...owned,
|
|
304
|
+
error: [ClientNotFound, InternalServerError],
|
|
285
305
|
input: ClientBlockInput,
|
|
286
306
|
success: Schema.Struct({ blocked: Schema.Boolean })
|
|
287
307
|
}),
|
|
@@ -289,6 +309,12 @@ const Administration = [
|
|
|
289
309
|
description: "Rotate a confidential client secret. Returns the new secret once.",
|
|
290
310
|
readOnly: false,
|
|
291
311
|
...owned,
|
|
312
|
+
error: [
|
|
313
|
+
ClientNotFound,
|
|
314
|
+
ClientIsAutomatic,
|
|
315
|
+
ProviderRefused,
|
|
316
|
+
InternalServerError
|
|
317
|
+
],
|
|
292
318
|
input: ClientId,
|
|
293
319
|
success: ClientCredentials
|
|
294
320
|
}),
|
|
@@ -296,6 +322,11 @@ const Administration = [
|
|
|
296
322
|
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.",
|
|
297
323
|
readOnly: false,
|
|
298
324
|
...owned,
|
|
325
|
+
error: [
|
|
326
|
+
ClientNotFound,
|
|
327
|
+
ClientIsAutomatic,
|
|
328
|
+
InternalServerError
|
|
329
|
+
],
|
|
299
330
|
input: ClientAccessInput,
|
|
300
331
|
success: ClientAccessResult
|
|
301
332
|
}),
|
|
@@ -303,6 +334,7 @@ const Administration = [
|
|
|
303
334
|
description: "Create an OAuth resource and its scopes.",
|
|
304
335
|
readOnly: false,
|
|
305
336
|
...owned,
|
|
337
|
+
error: [ProviderRefused, InternalServerError],
|
|
306
338
|
input: ResourceInput,
|
|
307
339
|
success: Resource.pipe(HttpApiSchema.status(201))
|
|
308
340
|
}),
|
|
@@ -310,6 +342,11 @@ const Administration = [
|
|
|
310
342
|
description: "Update an OAuth resource and its scopes.",
|
|
311
343
|
readOnly: false,
|
|
312
344
|
...owned,
|
|
345
|
+
error: [
|
|
346
|
+
ResourceNotFound,
|
|
347
|
+
ProviderRefused,
|
|
348
|
+
InternalServerError
|
|
349
|
+
],
|
|
313
350
|
input: ResourceInput,
|
|
314
351
|
success: Resource
|
|
315
352
|
}),
|
|
@@ -317,6 +354,7 @@ const Administration = [
|
|
|
317
354
|
description: "Delete a resource, its client access and every client’s authorization for it. API-key permissions are retained; recreating the resource restores them.",
|
|
318
355
|
readOnly: false,
|
|
319
356
|
...owned,
|
|
357
|
+
error: [ResourceNotFound, InternalServerError],
|
|
320
358
|
input: ResourceId,
|
|
321
359
|
success: Schema.Struct({ deleted: Schema.Boolean })
|
|
322
360
|
}),
|
|
@@ -324,48 +362,52 @@ const Administration = [
|
|
|
324
362
|
description: "List API key metadata without secret values.",
|
|
325
363
|
readOnly: true,
|
|
326
364
|
...owned,
|
|
327
|
-
|
|
365
|
+
error: [InternalServerError],
|
|
366
|
+
success: Schema.Struct({ keys: Schema.Array(ApiKey) })
|
|
328
367
|
}),
|
|
329
368
|
Action.make("createApiKey", {
|
|
330
369
|
description: "Create a scoped API key. Returns its secret once.",
|
|
331
370
|
readOnly: false,
|
|
332
371
|
...owned,
|
|
372
|
+
error: [ProviderRefused, InternalServerError],
|
|
333
373
|
input: ApiKeyInput,
|
|
334
|
-
success:
|
|
374
|
+
success: ApiKey.pipe(Schema.fieldsAssign({ key: Schema.String }), HttpApiSchema.status(201))
|
|
335
375
|
}),
|
|
336
376
|
Action.make("updateApiKey", {
|
|
337
377
|
description: "Update an API key’s name, permissions or enabled state.",
|
|
338
378
|
readOnly: false,
|
|
339
379
|
...owned,
|
|
380
|
+
error: [
|
|
381
|
+
ApiKeyNotFound,
|
|
382
|
+
ProviderRefused,
|
|
383
|
+
InternalServerError
|
|
384
|
+
],
|
|
340
385
|
input: ApiKeyUpdate,
|
|
341
|
-
success:
|
|
386
|
+
success: ApiKey
|
|
342
387
|
}),
|
|
343
388
|
Action.make("deleteApiKey", {
|
|
344
389
|
description: "Delete an API key.",
|
|
345
390
|
readOnly: false,
|
|
346
391
|
...owned,
|
|
392
|
+
error: [ApiKeyNotFound, InternalServerError],
|
|
347
393
|
input: ApiKeyId,
|
|
348
394
|
success: Schema.Struct({ deleted: Schema.Boolean })
|
|
349
395
|
})
|
|
350
396
|
];
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
397
|
+
[.../* @__PURE__ */ new Set([
|
|
398
|
+
...[...IssuerActions, ...Administration].flatMap((action) => action.error),
|
|
399
|
+
...undecided,
|
|
400
|
+
UnreadableRequest
|
|
401
|
+
])];
|
|
356
402
|
const binding = (authentication) => ActionHttp.make([...Administration, ...IssuerActions], { authentication });
|
|
357
403
|
binding(OwnerSession);
|
|
358
|
-
/**
|
|
359
|
-
* Owner administration over HTTP for a client holding an access token, such as the CLI, at
|
|
360
|
-
* `/api/owner/<action>`: the actions `/mcp` serves, authenticated as it is, by `OwnerToken`.
|
|
361
|
-
*/
|
|
362
404
|
const OwnerHttp = ActionHttp.make(Administration, {
|
|
363
405
|
prefix: "/api/owner",
|
|
364
406
|
authentication: OwnerToken
|
|
365
407
|
});
|
|
366
408
|
//#endregion
|
|
367
409
|
//#region package.json
|
|
368
|
-
var version = "0.
|
|
410
|
+
var version = "0.23.0";
|
|
369
411
|
//#endregion
|
|
370
412
|
//#region src/credentials.ts
|
|
371
413
|
/** The kept sign-ins cannot be read or written. */
|
|
@@ -402,17 +444,8 @@ const Credentials = Schema.Struct({
|
|
|
402
444
|
signIns: Schema.Array(SignIn)
|
|
403
445
|
});
|
|
404
446
|
const CredentialsJson = Schema.fromJsonString(Credentials);
|
|
405
|
-
/** Where the sign-ins are kept: `$XDG_CONFIG_HOME/clankerauth`, or `~/.config/clankerauth`. */
|
|
406
447
|
const credentialsPath = () => join(process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config"), "clankerauth", "credentials.json");
|
|
407
|
-
/**
|
|
408
|
-
* The sign-ins, if there are any. A file this version cannot decode, such as one another
|
|
409
|
-
* version wrote, holds none: the next `login` replaces it.
|
|
410
|
-
*/
|
|
411
448
|
const readCredentials = () => Effect.tryPromise(() => readFile(credentialsPath(), "utf8")).pipe(Effect.map(Schema.decodeOption(CredentialsJson)), Effect.catchIf((error) => error.cause instanceof Error && "code" in error.cause && error.cause.code === "ENOENT", () => Effect.succeedNone), Effect.mapError(failed("Cannot read")));
|
|
412
|
-
/**
|
|
413
|
-
* Replace the sign-ins, readable by their owner alone. Written beside the file and renamed
|
|
414
|
-
* over it, so a command interrupted mid-write never leaves a refresh token half-saved.
|
|
415
|
-
*/
|
|
416
449
|
const writeCredentials = Effect.fn("Credentials.write")(function* (credentials) {
|
|
417
450
|
const path = credentialsPath();
|
|
418
451
|
const json = yield* Schema.encodeEffect(CredentialsJson)(credentials);
|
|
@@ -426,10 +459,6 @@ const writeCredentials = Effect.fn("Credentials.write")(function* (credentials)
|
|
|
426
459
|
await rename(staging, path);
|
|
427
460
|
}).pipe(Effect.tapError(() => Effect.promise(() => rm(staging, { force: true }))));
|
|
428
461
|
}, Effect.mapError(failed("Cannot write")));
|
|
429
|
-
/**
|
|
430
|
-
* Keep `signIn` in place of the one for its resource. The file is read again first, so a
|
|
431
|
-
* command refreshing another resource meanwhile keeps its rotated refresh token too.
|
|
432
|
-
*/
|
|
433
462
|
const saveSignIn = Effect.fn("Credentials.saveSignIn")(function* (issuer, signIn) {
|
|
434
463
|
const current = Option.filter(yield* readCredentials(), ({ url }) => url === issuer.url);
|
|
435
464
|
const base = Option.getOrElse(current, () => issuer);
|
|
@@ -451,20 +480,13 @@ const Server = Schema.Struct({
|
|
|
451
480
|
registration_endpoint: Schema.String,
|
|
452
481
|
revocation_endpoint: Schema.String
|
|
453
482
|
});
|
|
454
|
-
/** This CLI as the issuer knows it: a public client, authenticating with nothing. */
|
|
455
483
|
const client = (clientId) => ({ client_id: clientId });
|
|
456
|
-
/** Plain HTTP is allowed for an issuer the owner named over it, such as one on loopback. */
|
|
457
484
|
const transport = (url) => ({ [oauth.allowInsecureRequests]: new URL(url).protocol === "http:" });
|
|
458
485
|
/** The RFC 9728 check's failure: the metadata names another resource than the one asked for. */
|
|
459
486
|
const NamedElsewhere = Schema.Struct({
|
|
460
487
|
attribute: Schema.Literal("resource"),
|
|
461
488
|
body: Schema.Struct({ resource: Schema.String })
|
|
462
489
|
});
|
|
463
|
-
/**
|
|
464
|
-
* What went wrong: what the issuer said when it sent an OAuth error, where it is when it
|
|
465
|
-
* names another address than the one asked, the status of an answer the library refused,
|
|
466
|
-
* or why a request failed.
|
|
467
|
-
*/
|
|
468
490
|
const reason = (error) => {
|
|
469
491
|
if (error instanceof oauth.ResponseBodyError || error instanceof oauth.AuthorizationResponseError) return [error.error, error.error_description].filter(Boolean).join(": ");
|
|
470
492
|
if (error instanceof oauth.OperationProcessingError) {
|
|
@@ -474,16 +496,10 @@ const reason = (error) => {
|
|
|
474
496
|
}
|
|
475
497
|
return error.cause instanceof Error ? `${error.message} (${error.cause.message})` : error.message;
|
|
476
498
|
};
|
|
477
|
-
/** Run one step of the protocol, failing with what the issuer, or the library, said. */
|
|
478
499
|
const step = (what, run) => Effect.tryPromise({
|
|
479
500
|
try: run,
|
|
480
501
|
catch: (error) => new OAuthError({ message: `${what}: ${error instanceof Error ? reason(error) : String(error)}` })
|
|
481
502
|
});
|
|
482
|
-
/**
|
|
483
|
-
* The issuer at `url`, its origin: administration's RFC 9728 metadata at the origin root,
|
|
484
|
-
* which must name that root, and the RFC 8414 metadata of the issuer it names, which must
|
|
485
|
-
* name that issuer. Both checks are the library's.
|
|
486
|
-
*/
|
|
487
503
|
const discover = Effect.fn("OAuth.discover")(function* (url) {
|
|
488
504
|
const administration = new URL("/", url);
|
|
489
505
|
const [named] = (yield* step(`Discovery at ${administration.origin}`, async () => oauth.processResourceDiscoveryResponse(administration, await oauth.resourceDiscoveryRequest(administration, transport(url))))).authorization_servers ?? [];
|
|
@@ -495,10 +511,6 @@ const discover = Effect.fn("OAuth.discover")(function* (url) {
|
|
|
495
511
|
})));
|
|
496
512
|
return yield* Schema.decodeUnknownEffect(Server)(server).pipe(Effect.mapError((error) => new OAuthError({ message: `Discovery: the issuer lacks an endpoint (${error.message})` })));
|
|
497
513
|
});
|
|
498
|
-
/**
|
|
499
|
-
* Register this CLI as a public native client. The issuer matches a loopback redirect without
|
|
500
|
-
* its port (RFC 8252), so one registration serves every port a sign-in listens on.
|
|
501
|
-
*/
|
|
502
514
|
const register = (url, server, redirectUri) => step("Registration", async () => oauth.processDynamicClientRegistrationResponse(await oauth.dynamicClientRegistrationRequest(server, {
|
|
503
515
|
client_name: "clankerauth CLI",
|
|
504
516
|
redirect_uris: [redirectUri],
|
|
@@ -507,10 +519,26 @@ const register = (url, server, redirectUri) => step("Registration", async () =>
|
|
|
507
519
|
grant_types: ["authorization_code", "refresh_token"],
|
|
508
520
|
response_types: ["code"]
|
|
509
521
|
}, transport(url)))).pipe(Effect.map((registered) => registered.client_id));
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
522
|
+
const authorizationRequest = Effect.fn("OAuth.authorizationRequest")(function* (server, clientId, redirectUri, scope, resource) {
|
|
523
|
+
const verifier = oauth.generateRandomCodeVerifier();
|
|
524
|
+
const state = oauth.generateRandomState();
|
|
525
|
+
const url = new URL(server.authorization_endpoint);
|
|
526
|
+
url.search = new URLSearchParams({
|
|
527
|
+
client_id: clientId,
|
|
528
|
+
redirect_uri: redirectUri,
|
|
529
|
+
response_type: "code",
|
|
530
|
+
scope,
|
|
531
|
+
resource,
|
|
532
|
+
code_challenge: yield* Effect.promise(() => oauth.calculatePKCECodeChallenge(verifier)),
|
|
533
|
+
code_challenge_method: "S256",
|
|
534
|
+
state
|
|
535
|
+
}).toString();
|
|
536
|
+
return {
|
|
537
|
+
url,
|
|
538
|
+
state,
|
|
539
|
+
verifier
|
|
540
|
+
};
|
|
541
|
+
});
|
|
514
542
|
const exchangeCode = (url, server, clientId, callback, expected, resource) => step("Sign-in", async () => {
|
|
515
543
|
const parameters = oauth.validateAuthResponse(server, client(clientId), callback, expected.state);
|
|
516
544
|
return oauth.processAuthorizationCodeResponse(server, client(clientId), await oauth.authorizationCodeGrantRequest(server, client(clientId), oauth.None(), parameters, expected.redirectUri, expected.verifier, {
|
|
@@ -518,45 +546,27 @@ const exchangeCode = (url, server, clientId, callback, expected, resource) => st
|
|
|
518
546
|
...transport(url)
|
|
519
547
|
}));
|
|
520
548
|
});
|
|
521
|
-
/** Refresh the tokens of one sign-in: the refresh token rotates. */
|
|
522
549
|
const refresh = (credentials, refreshToken, resource) => step("Refresh", async () => oauth.processRefreshTokenResponse(credentials.server, client(credentials.clientId), await oauth.refreshTokenGrantRequest(credentials.server, client(credentials.clientId), oauth.None(), refreshToken, {
|
|
523
550
|
additionalParameters: { resource },
|
|
524
551
|
...transport(credentials.url)
|
|
525
552
|
})));
|
|
526
|
-
/** Revoke a refresh token, and with it every token of its family. */
|
|
527
553
|
const revoke = (credentials, refreshToken) => step("Revocation", async () => oauth.processRevocationResponse(await oauth.revocationRequest(credentials.server, client(credentials.clientId), oauth.None(), refreshToken, {
|
|
528
554
|
additionalParameters: { token_type_hint: "refresh_token" },
|
|
529
555
|
...transport(credentials.url)
|
|
530
556
|
})));
|
|
531
557
|
//#endregion
|
|
532
558
|
//#region src/login.ts
|
|
533
|
-
|
|
534
|
-
const administrationOf = (url) => new URL("/", url).href;
|
|
535
|
-
/** The scopes a sign-in to administration asks for unless told otherwise. */
|
|
536
|
-
const administrationScopes = "clankerauth:read clankerauth:write";
|
|
537
|
-
/** A sign-in asks for a refresh token too, so it lasts while it is used. */
|
|
559
|
+
const administrationScope = `${administrationScopes.read} ${administrationScopes.write}`;
|
|
538
560
|
const withRefresh = (scope) => scope.split(" ").includes("offline_access") ? scope : `${scope} offline_access`;
|
|
539
|
-
/**
|
|
540
|
-
* The command that signs in to `resource` again: administration needs neither flag unless
|
|
541
|
-
* it was granted other scopes than its default, and another resource needs both.
|
|
542
|
-
*/
|
|
543
561
|
const loginHint = (url, resource, scope) => {
|
|
544
562
|
const asked = scope?.split(" ").filter((granted) => granted !== "offline_access").join(" ");
|
|
545
|
-
const administration = resource ===
|
|
546
|
-
return `clankerauth login ${url}${administration ? "" : ` --resource ${resource}`}${administration && (asked === void 0 || asked ===
|
|
563
|
+
const administration = resource === administrationIdentifier(url);
|
|
564
|
+
return `clankerauth login ${url}${administration ? "" : ` --resource ${resource}`}${administration && (asked === void 0 || asked === administrationScope) ? "" : ` --scope "${asked ?? "<scopes>"}"`}`;
|
|
547
565
|
};
|
|
548
|
-
/** The redirect a registration names; a sign-in adds the port it listens on. */
|
|
549
566
|
const registeredRedirect = "http://127.0.0.1/callback";
|
|
550
|
-
/** How long a sign-in waits for the owner to approve it in the browser. */
|
|
551
567
|
const approvalWindow = "5 minutes";
|
|
552
|
-
/** When tokens issued now expire; an issuer that does not say is asked again next time. */
|
|
553
568
|
const expiry = (tokens) => new Date(Date.now() + (tokens.expires_in ?? 0) * 1e3).toISOString();
|
|
554
|
-
/**
|
|
555
|
-
* The callback page says nothing of the outcome, which is decided after the browser has
|
|
556
|
-
* its answer: the terminal reports it.
|
|
557
|
-
*/
|
|
558
569
|
const page = "<!doctype html><meta charset=\"utf-8\"><title>clankerauth</title><p>Return to the terminal.</p>";
|
|
559
|
-
/** Listen on a free loopback port for the one request the authorization redirect makes. */
|
|
560
570
|
const listen = Effect.acquireRelease(Effect.gen(function* () {
|
|
561
571
|
const received = yield* Deferred.make();
|
|
562
572
|
const server = createServer((request, response) => {
|
|
@@ -580,21 +590,14 @@ const listen = Effect.acquireRelease(Effect.gen(function* () {
|
|
|
580
590
|
server.closeAllConnections();
|
|
581
591
|
server.close();
|
|
582
592
|
}));
|
|
583
|
-
/** Revoke these sign-ins' refresh tokens, as far as the issuer can be reached. */
|
|
584
593
|
const revokeAll = (credentials, signIns) => Effect.forEach(signIns, ({ refreshToken }) => Effect.ignore(revoke(credentials, refreshToken)), {
|
|
585
594
|
concurrency: "unbounded",
|
|
586
595
|
discard: true
|
|
587
596
|
});
|
|
588
|
-
/**
|
|
589
|
-
* Sign in to the issuer at `url` as its owner, for `resource`, administration when none:
|
|
590
|
-
* discover the issuer, register this CLI once per issuer, have the owner approve the URL
|
|
591
|
-
* printed on stderr, and keep the tokens beside the sign-ins to other resources. Signing
|
|
592
|
-
* in to another issuer than the kept one revokes and forgets the kept one's sign-ins.
|
|
593
|
-
*/
|
|
594
597
|
const login = Effect.fn("Cli.login")(function* (url, target, asked) {
|
|
595
598
|
const origin = new URL(url).origin;
|
|
596
599
|
const server = yield* discover(origin);
|
|
597
|
-
const resource = Option.getOrElse(target, () =>
|
|
600
|
+
const resource = Option.getOrElse(target, () => administrationIdentifier(origin));
|
|
598
601
|
const kept = yield* readCredentials();
|
|
599
602
|
const previous = Option.filter(kept, (credentials) => credentials.url === origin);
|
|
600
603
|
const clientId = yield* Option.match(previous, {
|
|
@@ -603,20 +606,8 @@ const login = Effect.fn("Cli.login")(function* (url, target, asked) {
|
|
|
603
606
|
});
|
|
604
607
|
const { port, received } = yield* listen;
|
|
605
608
|
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
606
|
-
const verifier = oauth.generateRandomCodeVerifier();
|
|
607
|
-
const state = oauth.generateRandomState();
|
|
608
609
|
const scope = withRefresh(asked);
|
|
609
|
-
const authorization =
|
|
610
|
-
authorization.search = new URLSearchParams({
|
|
611
|
-
client_id: clientId,
|
|
612
|
-
redirect_uri: redirectUri,
|
|
613
|
-
response_type: "code",
|
|
614
|
-
scope,
|
|
615
|
-
resource,
|
|
616
|
-
code_challenge: yield* Effect.promise(() => oauth.calculatePKCECodeChallenge(verifier)),
|
|
617
|
-
code_challenge_method: "S256",
|
|
618
|
-
state
|
|
619
|
-
}).toString();
|
|
610
|
+
const { url: authorization, state, verifier } = yield* authorizationRequest(server, clientId, redirectUri, scope, resource);
|
|
620
611
|
yield* Effect.sync(() => process.stderr.write(`Open this URL in your browser to approve the sign-in:\n\n ${authorization.href}\n\n`));
|
|
621
612
|
const callback = yield* Deferred.await(received).pipe(Effect.timeoutOrElse({
|
|
622
613
|
duration: approvalWindow,
|
|
@@ -655,10 +646,6 @@ const login = Effect.fn("Cli.login")(function* (url, target, asked) {
|
|
|
655
646
|
scope: signIn.scope
|
|
656
647
|
};
|
|
657
648
|
}, Effect.scoped);
|
|
658
|
-
/**
|
|
659
|
-
* A current access token for `resource`, refreshed first when it has expired or is about
|
|
660
|
-
* to: the refresh token rotates, so the new pair is saved before the token is used.
|
|
661
|
-
*/
|
|
662
649
|
const accessToken = Effect.fn("Cli.accessToken")(function* (credentials, resource) {
|
|
663
650
|
const signIn = credentials.signIns.find((held) => held.resource === resource);
|
|
664
651
|
if (signIn === void 0) return yield* new OAuthError({ message: `Not signed in to ${resource}: run \`${loginHint(credentials.url, resource)}\` first.` });
|
|
@@ -674,11 +661,6 @@ const accessToken = Effect.fn("Cli.accessToken")(function* (credentials, resourc
|
|
|
674
661
|
yield* saveSignIn(credentials, refreshed);
|
|
675
662
|
return refreshed.accessToken;
|
|
676
663
|
});
|
|
677
|
-
/**
|
|
678
|
-
* Forget the sign-in to `resource`, or every one when none is named, revoking each refresh
|
|
679
|
-
* token first when the issuer can be reached. The file goes with the last sign-in, and
|
|
680
|
-
* with a file no sign-in can be read from.
|
|
681
|
-
*/
|
|
682
664
|
const logout = Effect.fn("Cli.logout")(function* (resource) {
|
|
683
665
|
const stored = yield* readCredentials();
|
|
684
666
|
if (Option.isNone(stored)) {
|
|
@@ -700,18 +682,15 @@ const logout = Effect.fn("Cli.logout")(function* (resource) {
|
|
|
700
682
|
});
|
|
701
683
|
//#endregion
|
|
702
684
|
//#region src/main.ts
|
|
703
|
-
/** A failure of this CLI's own, rendered as its message on stderr, exiting 1. */
|
|
704
685
|
const refused = (error) => new CliError.UserError({
|
|
705
686
|
cause: error,
|
|
706
687
|
userMessage: error.message
|
|
707
688
|
});
|
|
708
689
|
const json = (value) => Console.log(JSON.stringify(value, null, 2));
|
|
709
|
-
/** A resource identifier as the issuer keeps it: canonical, `https://notes.example/`. */
|
|
710
690
|
const resourceFlag = (description) => Flag.String("resource").pipe(Flag.withDescription(description), Flag.mapTryCatch((resource) => new URL(resource).href, () => "--resource must be a URL, such as https://notes.example/"), Flag.optional);
|
|
711
|
-
/** The scopes a sign-in asks for: `--scope`, which another resource than administration needs. */
|
|
712
691
|
const scopeFor = Effect.fn("Cli.scopeFor")(function* (resource, scope) {
|
|
713
692
|
if (Option.isSome(scope)) return scope.value;
|
|
714
|
-
if (Option.isNone(resource)) return
|
|
693
|
+
if (Option.isNone(resource)) return administrationScope;
|
|
715
694
|
const message = `Name the scopes to ask ${resource.value} for with --scope.`;
|
|
716
695
|
return yield* new CliError.UserError({
|
|
717
696
|
cause: message,
|
|
@@ -721,10 +700,9 @@ const scopeFor = Effect.fn("Cli.scopeFor")(function* (resource, scope) {
|
|
|
721
700
|
const loginCommand = Command.make("login", {
|
|
722
701
|
url: Argument.String("url").pipe(Argument.withDescription("The issuer's origin, its CLANKERAUTH_BASE_URL")),
|
|
723
702
|
resource: resourceFlag("Sign in to this resource instead of administration, for `token` to use"),
|
|
724
|
-
scope: Flag.String("scope").pipe(Flag.withDescription(`The scopes to ask for, space-separated: needed with --resource, and "${
|
|
703
|
+
scope: Flag.String("scope").pipe(Flag.withDescription(`The scopes to ask for, space-separated: needed with --resource, and "${administrationScope}" for administration by default`), Flag.optional)
|
|
725
704
|
}, ({ url, resource, scope }) => scopeFor(resource, scope).pipe(Effect.flatMap((asked) => login(url, resource, asked).pipe(Effect.mapError(refused))), Effect.flatMap(json))).pipe(Command.withDescription("Sign in to an issuer as its owner, for administration or, with --resource, for another resource: approve the URL it prints in the browser. Sign-ins are kept in ~/.config/clankerauth."));
|
|
726
705
|
const logoutCommand = Command.make("logout", { resource: resourceFlag("Sign out of this resource alone") }, ({ resource }) => logout(resource).pipe(Effect.mapError(refused), Effect.flatMap(json))).pipe(Command.withDescription("Revoke the sign-ins, or the one to --resource, and forget them."));
|
|
727
|
-
/** The kept sign-ins, or how to make one. */
|
|
728
706
|
const signedIn = Effect.gen(function* () {
|
|
729
707
|
const stored = yield* readCredentials().pipe(Effect.mapError(refused));
|
|
730
708
|
if (Option.isNone(stored)) return yield* new CliError.UserError({
|
|
@@ -733,22 +711,16 @@ const signedIn = Effect.gen(function* () {
|
|
|
733
711
|
});
|
|
734
712
|
return stored.value;
|
|
735
713
|
});
|
|
736
|
-
/** Prints the token alone, so a script takes the whole output as it. */
|
|
737
714
|
const tokenCommand = Command.make("token", { resource: resourceFlag("The resource whose token to print; administration by default") }, ({ resource }) => Effect.gen(function* () {
|
|
738
715
|
const credentials = yield* signedIn;
|
|
739
|
-
const token = yield* accessToken(credentials, Option.getOrElse(resource, () =>
|
|
716
|
+
const token = yield* accessToken(credentials, Option.getOrElse(resource, () => administrationIdentifier(credentials.url))).pipe(Effect.mapError(refused));
|
|
740
717
|
yield* Effect.sync(() => process.stdout.write(`${token}\n`));
|
|
741
718
|
})).pipe(Command.withDescription("Print a current access token for a resource signed in to, refreshing it first when it has expired."));
|
|
742
|
-
/**
|
|
743
|
-
* The client every administration command calls through: the signed-in issuer's origin and
|
|
744
|
-
* a current administration access token, refreshed and saved first when it has expired.
|
|
745
|
-
*/
|
|
746
719
|
const administrationClient = Effect.gen(function* () {
|
|
747
720
|
const credentials = yield* signedIn;
|
|
748
|
-
const token = yield* accessToken(credentials,
|
|
721
|
+
const token = yield* accessToken(credentials, administrationIdentifier(credentials.url)).pipe(Effect.mapError(refused));
|
|
749
722
|
return HttpClient.mapRequest(yield* HttpClient.HttpClient, (request) => request.pipe(HttpClientRequest.prependUrl(credentials.url), HttpClientRequest.bearerToken(token)));
|
|
750
723
|
});
|
|
751
|
-
/** Every administration action, as a command of its own, called over `/api/owner`. */
|
|
752
724
|
const administration = Administration.map((action) => ActionCli.remoteCommand(OwnerHttp, action).pipe(Command.provideEffect(HttpClient.HttpClient, administrationClient)));
|
|
753
725
|
const cli = Command.make("clankerauth").pipe(Command.withDescription("Administer a clankerauth issuer as its owner, and hand other commands its tokens. Results are JSON on stdout; a created key or client secret is in it once."), Command.withSubcommands([
|
|
754
726
|
loginCommand,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjermundgaraba/clankerauth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "The clankerauth command line: sign in to an issuer as its owner and administer it over HTTP.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -21,17 +21,17 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@effect/platform-node": "4.0.4",
|
|
23
23
|
"@effect/platform-node-shared": "4.0.4",
|
|
24
|
-
"@gjermundgaraba/effect-actions": "0.
|
|
24
|
+
"@gjermundgaraba/effect-actions": "0.17.0",
|
|
25
25
|
"effect": "4.0.4",
|
|
26
26
|
"oauth4webapi": "3.8.8"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@clankerauth/admin-api": "0.1.0",
|
|
30
|
-
"@gjermundgaraba/clankerauth-dev": "0.
|
|
30
|
+
"@gjermundgaraba/clankerauth-dev": "0.23.0",
|
|
31
31
|
"@types/node": "26.6.4",
|
|
32
32
|
"typescript": "7.0.2",
|
|
33
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@1.
|
|
34
|
-
"vite-plus": "1.
|
|
33
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.2.0",
|
|
34
|
+
"vite-plus": "1.2.0"
|
|
35
35
|
},
|
|
36
36
|
"engines": {
|
|
37
37
|
"node": ">=26"
|