@crombolo/server 0.10.0 → 0.12.1
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/package.json +12 -11
- package/src/adapters/audit.ts +186 -0
- package/src/adapters/content.ts +2 -0
- package/src/adapters/documents.ts +28 -25
- package/src/adapters/elements.ts +3 -0
- package/src/adapters/health.ts +333 -0
- package/src/adapters/media.ts +3 -0
- package/src/adapters/members.ts +10 -2
- package/src/adapters/references.ts +13 -0
- package/src/adapters/relations.ts +48 -0
- package/src/adapters/schema-files.ts +41 -11
- package/src/adapters/schema-proposals.ts +13 -6
- package/src/adapters/search-index.ts +31 -0
- package/src/adapters/webhooks.ts +76 -0
- package/src/boot-failure.ts +147 -0
- package/src/bundle-install.ts +148 -13
- package/src/bundles.ts +14 -0
- package/src/busy.ts +13 -0
- package/src/config.ts +46 -2
- package/src/database.ts +9 -7
- package/src/delivery-members.ts +387 -0
- package/src/editor-types.ts +7 -2
- package/src/extensions-notice.ts +3 -2
- package/src/form-files.ts +38 -3
- package/src/form-submit.ts +62 -5
- package/src/form-types.ts +187 -0
- package/src/form-workflows.ts +66 -6
- package/src/forms.ts +15 -7
- package/src/index.ts +12 -0
- package/src/jobs.ts +91 -10
- package/src/media-files.ts +3 -1
- package/src/member-auth.ts +17 -1
- package/src/models-builder.ts +12 -8
- package/src/ports.ts +57 -2
- package/src/schema.ts +58 -11
- package/src/secrets.ts +36 -0
- package/src/server.ts +388 -29
- package/src/setup.ts +288 -0
- package/src/status.ts +5 -2
- package/src/transfer-dashboard.ts +219 -0
- package/src/upgrade-summary.ts +43 -0
- package/src/url-settings.ts +91 -0
- package/src/webhook-files.ts +138 -0
- package/src/webhooks.ts +544 -0
package/src/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { hostname, tmpdir } from 'node:os'
|
|
2
2
|
import { join, resolve } from 'node:path'
|
|
3
|
+
import type { DeliveryApiOptions } from '@crombolo/api-delivery'
|
|
3
4
|
import { DEFAULT_BACKOFFICE_PATH } from '@crombolo/core'
|
|
4
5
|
import { DEFAULT_RENDER_TIMEOUT_MS, type RedirectRule, type SnapshotLimits } from '@crombolo/render'
|
|
5
6
|
import type { AssistantConfig } from './assistant.ts'
|
|
@@ -65,8 +66,15 @@ export interface CromboloConfig {
|
|
|
65
66
|
/** SQLite file, or ':memory:'. Ignored when CROMBOLO_DB=postgres. */
|
|
66
67
|
sqliteFile: string
|
|
67
68
|
adminLogin: string
|
|
68
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Seeds the first administrator of a fresh database. Unset in development, one
|
|
71
|
+
* is generated and printed; unset in production, the site waits to be set up.
|
|
72
|
+
*/
|
|
69
73
|
adminPassword: string | undefined
|
|
74
|
+
/** Guards "Set up your new site". Chosen by the owner; never printed, logged or shown. */
|
|
75
|
+
setupCode: string | undefined
|
|
76
|
+
/** Seals secrets kept in the database, such as a webhook's headers; never stored. */
|
|
77
|
+
secretKey: string | undefined
|
|
70
78
|
/**
|
|
71
79
|
* The `__Host-` cookie prefix requires Secure. Browsers treat http://localhost
|
|
72
80
|
* as secure, so this stays on in local development.
|
|
@@ -161,6 +169,8 @@ export interface CromboloConfig {
|
|
|
161
169
|
keepAllVersionsNewerThanDays: number
|
|
162
170
|
keepLatestVersionPerDayForDays: number
|
|
163
171
|
}
|
|
172
|
+
/** How many days the audit log keeps an entry; 0 keeps them forever. docs/audit-log.md. */
|
|
173
|
+
auditRetentionDays: number
|
|
164
174
|
/** Where uploaded media files live; served at `/media/`. */
|
|
165
175
|
mediaDir: string
|
|
166
176
|
/**
|
|
@@ -240,6 +250,13 @@ export interface CromboloConfig {
|
|
|
240
250
|
* answers on. Every field may also be set in the backoffice, which wins.
|
|
241
251
|
*/
|
|
242
252
|
seo: SeoConfig
|
|
253
|
+
/**
|
|
254
|
+
* Umbraco's Delivery API (`docs/delivery-api.md`): off unless enabled.
|
|
255
|
+
* `CROMBOLO_DELIVERY_API`, `CROMBOLO_DELIVERY_API_KEY`,
|
|
256
|
+
* `CROMBOLO_DELIVERY_API_PUBLIC` and `CROMBOLO_DELIVERY_API_MEDIA`; member
|
|
257
|
+
* sign-in is configured here, since its clients and secrets are a list.
|
|
258
|
+
*/
|
|
259
|
+
deliveryApi: DeliveryApiOptions
|
|
243
260
|
/**
|
|
244
261
|
* Bundles whose server half this site runs, each the result of calling the
|
|
245
262
|
* factory its package exports. Imported and listed here by hand on purpose:
|
|
@@ -317,6 +334,7 @@ export const DEFAULTS = {
|
|
|
317
334
|
npmRegistry: 'https://registry.npmjs.org',
|
|
318
335
|
keepAllVersionsNewerThanDays: 7,
|
|
319
336
|
keepLatestVersionPerDayForDays: 90,
|
|
337
|
+
auditRetentionDays: 365,
|
|
320
338
|
memberRegistrationType: 'Member',
|
|
321
339
|
memberSessionMinutes: 60 * 24 * 14,
|
|
322
340
|
maxFailedPasswordAttempts: 5,
|
|
@@ -390,6 +408,8 @@ export function loadConfig(
|
|
|
390
408
|
sqliteFile: Bun.env.CROMBOLO_SQLITE_FILE ?? DEFAULTS.sqliteFile,
|
|
391
409
|
adminLogin: Bun.env.CROMBOLO_ADMIN_LOGIN ?? DEFAULTS.adminLogin,
|
|
392
410
|
adminPassword: Bun.env.CROMBOLO_ADMIN_PASSWORD,
|
|
411
|
+
setupCode: Bun.env.CROMBOLO_SETUP_CODE,
|
|
412
|
+
secretKey: Bun.env.CROMBOLO_SECRET_KEY,
|
|
393
413
|
secureCookies: Bun.env.CROMBOLO_INSECURE_COOKIES !== 'true',
|
|
394
414
|
siteName: Bun.env.CROMBOLO_SITE_NAME ?? DEFAULTS.siteName,
|
|
395
415
|
siteDir: cwd,
|
|
@@ -440,6 +460,10 @@ export function loadConfig(
|
|
|
440
460
|
DEFAULTS.keepLatestVersionPerDayForDays,
|
|
441
461
|
),
|
|
442
462
|
},
|
|
463
|
+
auditRetentionDays: nonNegative(
|
|
464
|
+
Bun.env.CROMBOLO_AUDIT_RETENTION_DAYS,
|
|
465
|
+
DEFAULTS.auditRetentionDays,
|
|
466
|
+
),
|
|
443
467
|
mediaDir: Bun.env.CROMBOLO_MEDIA_DIR ?? DEFAULTS.mediaDir,
|
|
444
468
|
mediaStore: undefined,
|
|
445
469
|
logsDir: Bun.env.CROMBOLO_LOGS_DIR ?? DEFAULTS.logsDir,
|
|
@@ -475,6 +499,15 @@ export function loadConfig(
|
|
|
475
499
|
sitemap: envFlag(Bun.env.CROMBOLO_SITEMAP),
|
|
476
500
|
indexable: envFlag(Bun.env.CROMBOLO_INDEXABLE),
|
|
477
501
|
}),
|
|
502
|
+
deliveryApi: stripUndefined({
|
|
503
|
+
enabled: envFlag(Bun.env.CROMBOLO_DELIVERY_API),
|
|
504
|
+
apiKey: Bun.env.CROMBOLO_DELIVERY_API_KEY || undefined,
|
|
505
|
+
publicAccess: envFlag(Bun.env.CROMBOLO_DELIVERY_API_PUBLIC),
|
|
506
|
+
media:
|
|
507
|
+
envFlag(Bun.env.CROMBOLO_DELIVERY_API_MEDIA) === undefined
|
|
508
|
+
? undefined
|
|
509
|
+
: { enabled: envFlag(Bun.env.CROMBOLO_DELIVERY_API_MEDIA) },
|
|
510
|
+
}),
|
|
478
511
|
// No environment variable: a bundle is an import, which is code.
|
|
479
512
|
bundles: [],
|
|
480
513
|
marketplaceUrl: Bun.env.CROMBOLO_MARKETPLACE_URL ?? DEFAULTS.marketplaceUrl,
|
|
@@ -486,6 +519,11 @@ export function loadConfig(
|
|
|
486
519
|
const merged: CromboloConfig = { ...fromEnv, ...stripUndefined(overrides) }
|
|
487
520
|
// Field by field, so a site setting its title suffix keeps the environment's switches.
|
|
488
521
|
merged.seo = { ...fromEnv.seo, ...stripUndefined(overrides.seo ?? {}) }
|
|
522
|
+
merged.deliveryApi = {
|
|
523
|
+
...fromEnv.deliveryApi,
|
|
524
|
+
...stripUndefined(overrides.deliveryApi ?? {}),
|
|
525
|
+
media: { ...fromEnv.deliveryApi.media, ...(overrides.deliveryApi?.media ?? {}) },
|
|
526
|
+
}
|
|
489
527
|
// A schema store makes backoffice writes durable, so it carries writability with
|
|
490
528
|
// it — that is the point of configuring one. An explicit setting still wins.
|
|
491
529
|
if (
|
|
@@ -518,6 +556,12 @@ export function loadConfig(
|
|
|
518
556
|
}
|
|
519
557
|
|
|
520
558
|
/** `true`/`false` from the environment; undefined when unset, so a default applies. */
|
|
559
|
+
/** A whole number of at least 0; anything else falls back, rather than ageing the log out by accident. */
|
|
560
|
+
function nonNegative(value: string | undefined, fallback: number): number {
|
|
561
|
+
const parsed = value === undefined ? Number.NaN : Number(value)
|
|
562
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
|
|
563
|
+
}
|
|
564
|
+
|
|
521
565
|
function envFlag(value: string | undefined): boolean | undefined {
|
|
522
566
|
return value === undefined ? undefined : value !== 'false'
|
|
523
567
|
}
|
|
@@ -553,4 +597,4 @@ export function describeDatabase(
|
|
|
553
597
|
}
|
|
554
598
|
}
|
|
555
599
|
|
|
556
|
-
export const VERSION = '0.
|
|
600
|
+
export const VERSION = '0.12.1'
|
package/src/database.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
ensureSystemMediaTypes,
|
|
15
15
|
ensureSystemMemberTypes,
|
|
16
16
|
INITIAL_STATE,
|
|
17
|
+
markSetupPending,
|
|
17
18
|
migrate,
|
|
18
19
|
pendingMigrations,
|
|
19
20
|
readState,
|
|
@@ -125,17 +126,18 @@ export async function installDatabase(
|
|
|
125
126
|
// Core first, so a bundle step always runs against a core it declared itself compatible with.
|
|
126
127
|
await migrateBundles(db, chains, { by: config.nodeId })
|
|
127
128
|
|
|
129
|
+
// In production a password is never made up: the pipeline supplies one, or
|
|
130
|
+
// the owner sets the site up in the browser (docs/operations.md).
|
|
131
|
+
const settingUp = !config.development && !config.adminPassword
|
|
128
132
|
const password = config.adminPassword ?? crypto.randomUUID().replaceAll('-', '').slice(0, 20)
|
|
129
133
|
const seeded = await seedIdentity(db, {
|
|
130
|
-
admin:
|
|
131
|
-
|
|
132
|
-
login: config.adminLogin,
|
|
133
|
-
email: config.adminLogin,
|
|
134
|
-
password,
|
|
135
|
-
},
|
|
134
|
+
admin: settingUp
|
|
135
|
+
? undefined
|
|
136
|
+
: { name: 'Administrator', login: config.adminLogin, email: config.adminLogin, password },
|
|
136
137
|
hashPassword,
|
|
137
138
|
passwordConfig: passwordConfigJson(),
|
|
138
139
|
})
|
|
140
|
+
if (seeded && settingUp) await markSetupPending(db)
|
|
139
141
|
|
|
140
142
|
await seedContent(db)
|
|
141
143
|
// A database from an older version gains the built-ins it lacks.
|
|
@@ -144,5 +146,5 @@ export async function installDatabase(
|
|
|
144
146
|
await ensureSystemMemberTypes(db)
|
|
145
147
|
await ensureSystemContentTypes(db)
|
|
146
148
|
|
|
147
|
-
return seeded && !config.adminPassword ? password : undefined
|
|
149
|
+
return seeded && !settingUp && !config.adminPassword ? password : undefined
|
|
148
150
|
}
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Member sign-in for the Delivery API, at Umbraco's
|
|
3
|
+
* `/umbraco/delivery/api/v1/security/member/*`: the authorization code flow
|
|
4
|
+
* with PKCE for an app a member signs in to, and client credentials for the
|
|
5
|
+
* members a site names in config. Tokens are checked against the member as
|
|
6
|
+
* they are now, so a member locked out, unapproved, deleted or moved between
|
|
7
|
+
* groups is held to it at once. docs/delivery-api.md, "Member sign-in".
|
|
8
|
+
*
|
|
9
|
+
* Where this departs from Umbraco, on purpose: PKCE must be S256, signing out
|
|
10
|
+
* ends the member's tokens, and a token is read from the `Authorization`
|
|
11
|
+
* header (or a form body), never the query string.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
type DeliveryApiSettings,
|
|
15
|
+
type DeliveryMember,
|
|
16
|
+
memberAuthorizationProblems,
|
|
17
|
+
} from '@crombolo/api-delivery'
|
|
18
|
+
import {
|
|
19
|
+
type Db,
|
|
20
|
+
DeliveryMemberTokenRepository,
|
|
21
|
+
MemberRepository,
|
|
22
|
+
pkceMatches,
|
|
23
|
+
} from '@crombolo/data'
|
|
24
|
+
import type { MemberAuth } from './member-auth.ts'
|
|
25
|
+
|
|
26
|
+
export const MEMBER_CLIENT_ID = 'umbraco-member'
|
|
27
|
+
const SCOPES = new Set(['openid', 'offline_access'])
|
|
28
|
+
const CODE_LIFETIME_MS = 5 * 60_000
|
|
29
|
+
|
|
30
|
+
/** OpenIddict renders an authorize or sign-out error as plain `key:value` lines, never a redirect. */
|
|
31
|
+
const local = (error: string, description: string) =>
|
|
32
|
+
new Response(`error:${error}\nerror_description:${description}\n`, {
|
|
33
|
+
status: 400,
|
|
34
|
+
headers: { 'content-type': 'text/plain;charset=UTF-8' },
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const tokenError = (error: string, description: string, status = 400) =>
|
|
38
|
+
Response.json(
|
|
39
|
+
{ error, error_description: description },
|
|
40
|
+
{
|
|
41
|
+
status,
|
|
42
|
+
headers: status === 401 ? { 'www-authenticate': 'Basic realm="Umbraco"' } : undefined,
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const isAbsolute = (url: string) => {
|
|
47
|
+
try {
|
|
48
|
+
return Boolean(new URL(url).protocol) && !url.includes('#')
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A request's form fields, and HTTP Basic client credentials when sent. */
|
|
55
|
+
async function formOf(request: Request): Promise<Record<string, string>> {
|
|
56
|
+
const out: Record<string, string> = {}
|
|
57
|
+
const type = request.headers.get('content-type') ?? ''
|
|
58
|
+
if (type.includes('application/x-www-form-urlencoded') || type.includes('multipart/form-data')) {
|
|
59
|
+
const form = await request.formData().catch(() => undefined)
|
|
60
|
+
for (const [key, value] of form?.entries() ?? [])
|
|
61
|
+
if (typeof value === 'string') out[key] = value
|
|
62
|
+
}
|
|
63
|
+
const basic = /^Basic\s+(.+)$/i.exec(request.headers.get('authorization') ?? '')
|
|
64
|
+
if (basic) {
|
|
65
|
+
const [id, secret] = Buffer.from(basic[1] as string, 'base64')
|
|
66
|
+
.toString('utf8')
|
|
67
|
+
.split(':')
|
|
68
|
+
if (id && out.client_id === undefined) out.client_id = decodeURIComponent(id)
|
|
69
|
+
if (secret !== undefined && out.client_secret === undefined)
|
|
70
|
+
out.client_secret = decodeURIComponent(secret)
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface DeliveryMemberSecurity {
|
|
76
|
+
/** The member a bearer token names, as they are now; undefined when none may be. */
|
|
77
|
+
member(token: string): Promise<DeliveryMember | undefined>
|
|
78
|
+
handle(request: Request, action: string): Promise<Response | undefined>
|
|
79
|
+
/** Said once at boot: what turns a flow off. */
|
|
80
|
+
problems: string[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createDeliveryMemberSecurity(options: {
|
|
84
|
+
db: Db
|
|
85
|
+
settings: DeliveryApiSettings
|
|
86
|
+
members: Pick<MemberAuth, 'resolve' | 'signOutCookie'>
|
|
87
|
+
}): DeliveryMemberSecurity {
|
|
88
|
+
const { db, settings } = options
|
|
89
|
+
const tokens = new DeliveryMemberTokenRepository(db)
|
|
90
|
+
const memberRepo = new MemberRepository(db)
|
|
91
|
+
const code = settings.memberAuthorization.authorizationCodeFlow
|
|
92
|
+
const credentials = settings.memberAuthorization.clientCredentialsFlow
|
|
93
|
+
const problems = memberAuthorizationProblems(settings)
|
|
94
|
+
// Umbraco removes the app client when its flow is misconfigured, and every
|
|
95
|
+
// authorize call then names the client invalid; so does this.
|
|
96
|
+
const codeFlowOn =
|
|
97
|
+
code.enabled &&
|
|
98
|
+
code.loginRedirectUrls.length > 0 &&
|
|
99
|
+
code.loginRedirectUrls.every(isAbsolute) &&
|
|
100
|
+
code.logoutRedirectUrls.every(isAbsolute)
|
|
101
|
+
const timeoutMs = settings.memberAuthorization.timeoutMinutes * 60_000
|
|
102
|
+
|
|
103
|
+
const live = async (memberKey: string): Promise<DeliveryMember | undefined> => {
|
|
104
|
+
const member = await memberRepo.byKey(memberKey)
|
|
105
|
+
if (!member) return undefined
|
|
106
|
+
return {
|
|
107
|
+
key: member.key,
|
|
108
|
+
userName: member.username,
|
|
109
|
+
groups: member.roles,
|
|
110
|
+
approved: member.isApproved,
|
|
111
|
+
lockedOut: member.isLockedOut,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const grant = async (memberKey: string, clientId: string, scopes: string[]) => {
|
|
116
|
+
await tokens.purgeExpired()
|
|
117
|
+
const access = await tokens.issue({
|
|
118
|
+
kind: 'access',
|
|
119
|
+
memberKey,
|
|
120
|
+
clientId,
|
|
121
|
+
scopes,
|
|
122
|
+
redirectUri: null,
|
|
123
|
+
codeChallenge: null,
|
|
124
|
+
lifetimeMs: timeoutMs / 4,
|
|
125
|
+
})
|
|
126
|
+
const body: Record<string, unknown> = {
|
|
127
|
+
access_token: access.token,
|
|
128
|
+
token_type: 'Bearer',
|
|
129
|
+
expires_in: Math.round(timeoutMs / 4 / 1000),
|
|
130
|
+
scope: scopes.join(' '),
|
|
131
|
+
}
|
|
132
|
+
if (scopes.includes('offline_access'))
|
|
133
|
+
body.refresh_token = (
|
|
134
|
+
await tokens.issue({
|
|
135
|
+
kind: 'refresh',
|
|
136
|
+
memberKey,
|
|
137
|
+
clientId,
|
|
138
|
+
scopes,
|
|
139
|
+
redirectUri: null,
|
|
140
|
+
codeChallenge: null,
|
|
141
|
+
lifetimeMs: timeoutMs,
|
|
142
|
+
})
|
|
143
|
+
).token
|
|
144
|
+
return Response.json(body, { headers: { 'cache-control': 'no-store' } })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function authorize(request: Request): Promise<Response> {
|
|
148
|
+
const url = new URL(request.url)
|
|
149
|
+
const q = (name: string) => url.searchParams.get(name) ?? ''
|
|
150
|
+
if (!q('client_id'))
|
|
151
|
+
return local('invalid_request', "The mandatory 'client_id' parameter is missing.")
|
|
152
|
+
if (q('client_id') !== MEMBER_CLIENT_ID || !codeFlowOn)
|
|
153
|
+
return local('invalid_request', "The specified 'client_id' is invalid.")
|
|
154
|
+
if (!q('redirect_uri'))
|
|
155
|
+
return local('invalid_request', "The mandatory 'redirect_uri' parameter is missing.")
|
|
156
|
+
if (!isAbsolute(q('redirect_uri')))
|
|
157
|
+
return local('invalid_request', "The 'redirect_uri' parameter must be a valid absolute URI.")
|
|
158
|
+
if (!q('response_type'))
|
|
159
|
+
return local('invalid_request', "The mandatory 'response_type' parameter is missing.")
|
|
160
|
+
if (q('response_type') !== 'code')
|
|
161
|
+
return local('unsupported_response_type', "The specified 'response_type' is not supported.")
|
|
162
|
+
if (!q('code_challenge'))
|
|
163
|
+
return local('invalid_request', "The mandatory 'code_challenge' parameter is missing.")
|
|
164
|
+
// S256 only: Umbraco also allows `plain`, which a stolen code defeats.
|
|
165
|
+
if (q('code_challenge_method') !== 'S256')
|
|
166
|
+
return local('invalid_request', "The specified 'code_challenge_method' is not supported.")
|
|
167
|
+
if (!code.loginRedirectUrls.includes(q('redirect_uri')))
|
|
168
|
+
return local(
|
|
169
|
+
'invalid_request',
|
|
170
|
+
"The specified 'redirect_uri' is not valid for this client application.",
|
|
171
|
+
)
|
|
172
|
+
const scopes = q('scope').split(' ').filter(Boolean)
|
|
173
|
+
if (!scopes.every((s) => SCOPES.has(s)))
|
|
174
|
+
return local('invalid_scope', "The specified 'scope' is invalid.")
|
|
175
|
+
|
|
176
|
+
const signedIn = await options.members.resolve(request)
|
|
177
|
+
if (!signedIn) {
|
|
178
|
+
if (!code.loginPage)
|
|
179
|
+
return local(
|
|
180
|
+
'login_required',
|
|
181
|
+
'This site has no member login page set for signing in from an app.',
|
|
182
|
+
)
|
|
183
|
+
const login = new URL(code.loginPage, url)
|
|
184
|
+
login.searchParams.set('returnUrl', `${url.pathname}${url.search}`)
|
|
185
|
+
return new Response(null, {
|
|
186
|
+
status: 302,
|
|
187
|
+
headers: { location: `${login.pathname}${login.search}` },
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
const issued = await tokens.issue({
|
|
191
|
+
kind: 'code',
|
|
192
|
+
memberKey: signedIn.key,
|
|
193
|
+
clientId: MEMBER_CLIENT_ID,
|
|
194
|
+
scopes,
|
|
195
|
+
redirectUri: q('redirect_uri'),
|
|
196
|
+
codeChallenge: q('code_challenge'),
|
|
197
|
+
lifetimeMs: CODE_LIFETIME_MS,
|
|
198
|
+
})
|
|
199
|
+
const back = new URL(q('redirect_uri'))
|
|
200
|
+
back.searchParams.set('code', issued.token)
|
|
201
|
+
if (q('state')) back.searchParams.set('state', q('state'))
|
|
202
|
+
back.searchParams.set('iss', `${url.origin}/`)
|
|
203
|
+
return new Response(null, { status: 302, headers: { location: back.toString() } })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function token(request: Request): Promise<Response> {
|
|
207
|
+
if (request.method !== 'POST') return new Response(null, { status: 405 })
|
|
208
|
+
const form = await formOf(request)
|
|
209
|
+
const grantType = form.grant_type ?? ''
|
|
210
|
+
if (!grantType)
|
|
211
|
+
return tokenError('invalid_request', "The mandatory 'grant_type' parameter is missing.")
|
|
212
|
+
if (!['authorization_code', 'refresh_token', 'client_credentials'].includes(grantType))
|
|
213
|
+
return tokenError('unsupported_grant_type', "The specified 'grant_type' is not supported.")
|
|
214
|
+
const clientId = form.client_id ?? ''
|
|
215
|
+
if (!clientId)
|
|
216
|
+
return tokenError('invalid_client', "The mandatory 'client_id' parameter is missing.", 401)
|
|
217
|
+
|
|
218
|
+
if (grantType === 'client_credentials') {
|
|
219
|
+
const configured = credentials.enabled
|
|
220
|
+
? credentials.associatedMembers.find((m) => m.clientId === clientId)
|
|
221
|
+
: undefined
|
|
222
|
+
if (!configured)
|
|
223
|
+
return tokenError('invalid_client', "The specified 'client_id' is invalid.", 401)
|
|
224
|
+
if (!form.client_secret)
|
|
225
|
+
return tokenError(
|
|
226
|
+
'invalid_client',
|
|
227
|
+
'Client authentication is required for this application.',
|
|
228
|
+
401,
|
|
229
|
+
)
|
|
230
|
+
if (form.client_secret !== configured.clientSecret)
|
|
231
|
+
return tokenError('invalid_client', 'The specified client credentials are invalid.', 401)
|
|
232
|
+
const scopes = (form.scope ?? '').split(' ').filter(Boolean)
|
|
233
|
+
if (scopes.includes('offline_access'))
|
|
234
|
+
return tokenError(
|
|
235
|
+
'invalid_request',
|
|
236
|
+
"The client application is not allowed to use the 'offline_access' scope.",
|
|
237
|
+
)
|
|
238
|
+
const member = await memberRepo.byUsername(configured.userName)
|
|
239
|
+
if (!member)
|
|
240
|
+
return tokenError('Authorization failed', "Invalid 'client_id' or client configuration.")
|
|
241
|
+
return grant(
|
|
242
|
+
member.key,
|
|
243
|
+
clientId,
|
|
244
|
+
scopes.filter((s) => SCOPES.has(s)),
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (clientId !== MEMBER_CLIENT_ID || !codeFlowOn)
|
|
249
|
+
return tokenError('invalid_client', "The specified 'client_id' is invalid.", 401)
|
|
250
|
+
if (form.client_secret)
|
|
251
|
+
return tokenError(
|
|
252
|
+
'invalid_client',
|
|
253
|
+
"The 'client_secret' parameter is not valid for this client application.",
|
|
254
|
+
401,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if (grantType === 'authorization_code') {
|
|
258
|
+
if (!form.code)
|
|
259
|
+
return tokenError('invalid_request', "The mandatory 'code' parameter is missing.")
|
|
260
|
+
if (!form.code_verifier)
|
|
261
|
+
return tokenError('invalid_request', "The mandatory 'code_verifier' parameter is missing.")
|
|
262
|
+
const found = await tokens.find(form.code)
|
|
263
|
+
if (found?.kind !== 'code' || found.clientId !== MEMBER_CLIENT_ID)
|
|
264
|
+
return tokenError('invalid_grant', 'The specified authorization code is invalid.')
|
|
265
|
+
if (found.redeemedAt)
|
|
266
|
+
return tokenError(
|
|
267
|
+
'invalid_grant',
|
|
268
|
+
'The specified authorization code has already been redeemed.',
|
|
269
|
+
)
|
|
270
|
+
if (found.expiresAt.getTime() < Date.now())
|
|
271
|
+
return tokenError('invalid_grant', 'The specified authorization code is no longer valid.')
|
|
272
|
+
if ((form.redirect_uri ?? '') !== found.redirectUri)
|
|
273
|
+
return tokenError(
|
|
274
|
+
'invalid_grant',
|
|
275
|
+
"The specified 'redirect_uri' parameter doesn't match the client redirection URI the authorization code was initially sent to.",
|
|
276
|
+
)
|
|
277
|
+
if (!found.codeChallenge || !pkceMatches(form.code_verifier, found.codeChallenge))
|
|
278
|
+
return tokenError('invalid_grant', "The specified 'code_verifier' is invalid.")
|
|
279
|
+
if (!(await tokens.redeem(form.code)))
|
|
280
|
+
return tokenError(
|
|
281
|
+
'invalid_grant',
|
|
282
|
+
'The specified authorization code has already been redeemed.',
|
|
283
|
+
)
|
|
284
|
+
return grant(found.memberKey, MEMBER_CLIENT_ID, found.scopes)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// A refresh: rolled, so the one presented is spent.
|
|
288
|
+
const presented = form.refresh_token ?? ''
|
|
289
|
+
if (!presented)
|
|
290
|
+
return tokenError('invalid_request', "The mandatory 'refresh_token' parameter is missing.")
|
|
291
|
+
const found = await tokens.find(presented)
|
|
292
|
+
if (found?.kind !== 'refresh' || found.clientId !== MEMBER_CLIENT_ID)
|
|
293
|
+
return tokenError('invalid_grant', 'The specified refresh token is invalid.')
|
|
294
|
+
if (found.redeemedAt)
|
|
295
|
+
return tokenError('invalid_grant', 'The specified refresh token has already been redeemed.')
|
|
296
|
+
if (found.expiresAt.getTime() < Date.now())
|
|
297
|
+
return tokenError('invalid_grant', 'The specified refresh token is no longer valid.')
|
|
298
|
+
if (!(await tokens.redeem(presented)))
|
|
299
|
+
return tokenError('invalid_grant', 'The specified refresh token has already been redeemed.')
|
|
300
|
+
return grant(found.memberKey, MEMBER_CLIENT_ID, found.scopes)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function signOut(request: Request): Promise<Response> {
|
|
304
|
+
const url = new URL(request.url)
|
|
305
|
+
const target = url.searchParams.get('post_logout_redirect_uri')
|
|
306
|
+
if (target !== null) {
|
|
307
|
+
if (!isAbsolute(target))
|
|
308
|
+
return local(
|
|
309
|
+
'invalid_request',
|
|
310
|
+
"The 'post_logout_redirect_uri' parameter must be a valid absolute URI.",
|
|
311
|
+
)
|
|
312
|
+
if (!code.logoutRedirectUrls.includes(target))
|
|
313
|
+
return local('invalid_request', "The specified 'post_logout_redirect_uri' is invalid.")
|
|
314
|
+
}
|
|
315
|
+
// Umbraco only clears the cookie; here the member's tokens end too.
|
|
316
|
+
const signedIn = await options.members.resolve(request)
|
|
317
|
+
if (signedIn) await tokens.revokeMember(signedIn.key)
|
|
318
|
+
const headers = new Headers({ 'set-cookie': options.members.signOutCookie() })
|
|
319
|
+
if (!target) return new Response(null, { status: 200, headers })
|
|
320
|
+
const back = new URL(target)
|
|
321
|
+
const state = url.searchParams.get('state')
|
|
322
|
+
if (state) back.searchParams.set('state', state)
|
|
323
|
+
headers.set('location', back.toString())
|
|
324
|
+
return new Response(null, { status: 302, headers })
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function revoke(request: Request): Promise<Response> {
|
|
328
|
+
if (request.method !== 'POST') return new Response(null, { status: 405 })
|
|
329
|
+
const form = await formOf(request)
|
|
330
|
+
if (!form.token)
|
|
331
|
+
return tokenError('invalid_request', "The mandatory 'token' parameter is missing.")
|
|
332
|
+
const found = await tokens.find(form.token)
|
|
333
|
+
if (found && found.clientId !== (form.client_id ?? ''))
|
|
334
|
+
return tokenError(
|
|
335
|
+
'invalid_token',
|
|
336
|
+
'The client application is not allowed to revoke the specified token.',
|
|
337
|
+
)
|
|
338
|
+
if (found) await tokens.revoke(form.token)
|
|
339
|
+
return new Response(null, { status: 200 })
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const member = async (token: string): Promise<DeliveryMember | undefined> => {
|
|
343
|
+
const found = await tokens.find(token)
|
|
344
|
+
if (found?.kind !== 'access' || found.expiresAt.getTime() < Date.now()) return undefined
|
|
345
|
+
return live(found.memberKey)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function userinfo(request: Request): Promise<Response> {
|
|
349
|
+
const bearer = /^Bearer\s+(.+)$/i.exec(request.headers.get('authorization') ?? '')?.[1]
|
|
350
|
+
const found = bearer ? await member(bearer) : undefined
|
|
351
|
+
const record = found ? await memberRepo.byKey(found.key) : undefined
|
|
352
|
+
if (!found || !record || !found.approved || found.lockedOut)
|
|
353
|
+
return new Response(null, {
|
|
354
|
+
status: 401,
|
|
355
|
+
headers: {
|
|
356
|
+
'www-authenticate': `Bearer error="${bearer ? 'invalid_token' : 'missing_token'}"`,
|
|
357
|
+
},
|
|
358
|
+
})
|
|
359
|
+
return Response.json({
|
|
360
|
+
sub: record.key,
|
|
361
|
+
...(record.name ? { name: record.name } : {}),
|
|
362
|
+
...(record.email ? { email: record.email } : {}),
|
|
363
|
+
role: record.roles,
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
member,
|
|
369
|
+
problems,
|
|
370
|
+
async handle(request, action) {
|
|
371
|
+
switch (action) {
|
|
372
|
+
case 'authorize':
|
|
373
|
+
return authorize(request)
|
|
374
|
+
case 'token':
|
|
375
|
+
return token(request)
|
|
376
|
+
case 'signout':
|
|
377
|
+
return signOut(request)
|
|
378
|
+
case 'revoke':
|
|
379
|
+
return revoke(request)
|
|
380
|
+
case 'userinfo':
|
|
381
|
+
return userinfo(request)
|
|
382
|
+
default:
|
|
383
|
+
return undefined
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
}
|
|
387
|
+
}
|
package/src/editor-types.ts
CHANGED
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
* generated file is the schema's `content-types.d.ts`, read fresh on every
|
|
10
10
|
* request so a content type added a minute ago is already in the editor.
|
|
11
11
|
*/
|
|
12
|
+
|
|
12
13
|
import { existsSync, readFileSync } from 'node:fs'
|
|
13
14
|
import { readdir, readFile } from 'node:fs/promises'
|
|
14
15
|
import { createRequire } from 'node:module'
|
|
15
16
|
import { dirname, join } from 'node:path'
|
|
16
17
|
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import type { FormTypes } from '@crombolo/core'
|
|
17
19
|
import { intendedTypes } from './models-builder.ts'
|
|
18
20
|
|
|
19
21
|
/** A file in the editor's virtual file system. `path` is a URL, not a disk path. */
|
|
@@ -97,9 +99,12 @@ export function siteJsxImportSource(siteDir: string): JsxImportSource {
|
|
|
97
99
|
: '@crombolo/crombolo'
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
export async function editorTypeLibs(options: {
|
|
102
|
+
export async function editorTypeLibs(options: {
|
|
103
|
+
schemaDir: string
|
|
104
|
+
formTypes?: FormTypes
|
|
105
|
+
}): Promise<EditorTypeLib[]> {
|
|
101
106
|
const libs = [...(await moduleLibs())]
|
|
102
|
-
const intended = intendedTypes(options.schemaDir)
|
|
107
|
+
const intended = intendedTypes(options.schemaDir, options.formTypes)
|
|
103
108
|
// Left out rather than stubbed when the schema does not generate: a view that
|
|
104
109
|
// imports it then says the file is missing, which is true, instead of being
|
|
105
110
|
// checked against types that silently have nothing in them.
|
package/src/extensions-notice.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one trace App_Plugins leaves behind.
|
|
3
3
|
*
|
|
4
|
-
* Extensions are npm dependencies
|
|
4
|
+
* Extensions are bundles now — npm dependencies, or the site's own under
|
|
5
|
+
* `bundles/` (`docs/bundles.md`) — and nothing
|
|
5
6
|
* reads this directory any more. A site upgrading with files still in it would
|
|
6
7
|
* otherwise find its extensions had quietly stopped loading, so the boot says
|
|
7
8
|
* what happened. Remove at 1.0.
|
|
@@ -29,7 +30,7 @@ export function warnAboutAppPlugins(siteDir: string, log: NoticeLog): void {
|
|
|
29
30
|
count: entries.length,
|
|
30
31
|
dir,
|
|
31
32
|
remedy:
|
|
32
|
-
'Backoffice extensions are
|
|
33
|
+
'Backoffice extensions are bundles now: an npm dependency, installed with `bun add`, or one of the site’s own under bundles/. See docs/bundles.md.',
|
|
33
34
|
},
|
|
34
35
|
)
|
|
35
36
|
}
|