@crombolo/server 0.11.0 → 0.13.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/bundles.ts +2 -1
- 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/webhooks.ts +76 -0
- package/src/approvals-api.ts +189 -0
- package/src/approvals.ts +823 -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 +54 -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 +11 -0
- package/src/jobs.ts +17 -0
- package/src/marketplace.ts +18 -0
- 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 +66 -2
- package/src/schema.ts +58 -11
- package/src/secrets.ts +36 -0
- package/src/server.ts +388 -57
- 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
|
@@ -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
|
}
|
package/src/form-files.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
14
|
import { join } from 'node:path'
|
|
15
|
-
import type { SchemaForm } from '@crombolo/core'
|
|
15
|
+
import type { FormTypes, SchemaForm } from '@crombolo/core'
|
|
16
16
|
import {
|
|
17
17
|
fileNameFor,
|
|
18
18
|
loadSchemaDirectory,
|
|
@@ -28,6 +28,8 @@ export interface FormFileOptions {
|
|
|
28
28
|
writable: boolean
|
|
29
29
|
/** Publishes the directory to a shared store, when there is one. */
|
|
30
30
|
onChanged?: () => Promise<void>
|
|
31
|
+
/** The field and workflow types the site's wired bundles add. */
|
|
32
|
+
formTypes?: FormTypes
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export type FormWriteResult =
|
|
@@ -77,6 +79,7 @@ export function coerceForm(value: unknown): SchemaForm {
|
|
|
77
79
|
notes: text('notes'),
|
|
78
80
|
storeEntries: flag('storeEntries', true),
|
|
79
81
|
requiresApproval: flag('requiresApproval', false),
|
|
82
|
+
...(body.stepPages === true ? { stepPages: true } : {}),
|
|
80
83
|
submitLabel: text('submitLabel'),
|
|
81
84
|
nextLabel: text('nextLabel'),
|
|
82
85
|
previousLabel: text('previousLabel'),
|
|
@@ -140,6 +143,8 @@ function coerceField(value: unknown): Field {
|
|
|
140
143
|
cssClass: text('cssClass'),
|
|
141
144
|
autocomplete: text('autocomplete'),
|
|
142
145
|
values: strings(body.values),
|
|
146
|
+
...coerceSource(body.source),
|
|
147
|
+
...coerceSettings(body.settings),
|
|
143
148
|
placeholder: text('placeholder'),
|
|
144
149
|
defaultValue: text('defaultValue'),
|
|
145
150
|
content: text('content'),
|
|
@@ -155,6 +160,35 @@ function coerceField(value: unknown): Field {
|
|
|
155
160
|
}
|
|
156
161
|
}
|
|
157
162
|
|
|
163
|
+
/** Kept as sent: the validator names a kind it does not know. */
|
|
164
|
+
/** A bundle type's settings, kept as the designer sent them; the validator holds them to the declarations. */
|
|
165
|
+
function coerceSettings(value: unknown): { settings?: Field['settings'] } {
|
|
166
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
|
167
|
+
const settings: NonNullable<Field['settings']> = {}
|
|
168
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
169
|
+
if (entry === '' || entry === null || entry === undefined) continue
|
|
170
|
+
if (
|
|
171
|
+
typeof entry === 'string' ||
|
|
172
|
+
typeof entry === 'boolean' ||
|
|
173
|
+
(typeof entry === 'number' && Number.isFinite(entry))
|
|
174
|
+
)
|
|
175
|
+
settings[key] = entry
|
|
176
|
+
else if (Array.isArray(entry)) settings[key] = entry.filter((item) => typeof item === 'string')
|
|
177
|
+
}
|
|
178
|
+
return Object.keys(settings).length > 0 ? { settings } : {}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function coerceSource(value: unknown): { source?: Field['source'] } {
|
|
182
|
+
if (!value || typeof value !== 'object') return {}
|
|
183
|
+
const body = value as Record<string, unknown>
|
|
184
|
+
return {
|
|
185
|
+
source: {
|
|
186
|
+
kind: String(body.kind ?? '') as NonNullable<Field['source']>['kind'],
|
|
187
|
+
parent: typeof body.parent === 'string' ? body.parent : '',
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
158
192
|
function coerceCondition(value: unknown): Field['condition'] {
|
|
159
193
|
if (!value || typeof value !== 'object') return undefined
|
|
160
194
|
const body = value as Record<string, unknown>
|
|
@@ -196,18 +230,19 @@ export async function saveForm(
|
|
|
196
230
|
// Written then read back with the strict parser, so the file the designer
|
|
197
231
|
// would produce is held to exactly what a hand-written one is.
|
|
198
232
|
const name = formFileName(candidate.alias || 'form')
|
|
199
|
-
const reparsed = parseForm(`schema/forms/${name}`, writeForm(candidate))
|
|
233
|
+
const reparsed = parseForm(`schema/forms/${name}`, writeForm(candidate), options.formTypes)
|
|
200
234
|
if (!reparsed.value) return { ok: false, status: 'invalid', problems: reparsed.problems }
|
|
201
235
|
const form = reparsed.value
|
|
202
236
|
|
|
203
237
|
// Against the whole set, because an alias has to be unique across forms and a
|
|
204
238
|
// workflow is held to a content type that exists.
|
|
205
|
-
const loaded = loadSchemaDirectory(options.schemaDir)
|
|
239
|
+
const loaded = loadSchemaDirectory(options.schemaDir, { formTypes: options.formTypes })
|
|
206
240
|
const others = (loaded.set.forms ?? []).filter(
|
|
207
241
|
(candidate) => candidate.alias.toLowerCase() !== form.alias.toLowerCase(),
|
|
208
242
|
)
|
|
209
243
|
const problems = validateForms([...others, form], {
|
|
210
244
|
contentTypeAliases: new Set(loaded.set.contentTypes.map((type) => type.alias)),
|
|
245
|
+
types: options.formTypes,
|
|
211
246
|
})
|
|
212
247
|
if (problems.length > 0) return { ok: false, status: 'invalid', problems }
|
|
213
248
|
|
package/src/form-submit.ts
CHANGED
|
@@ -18,13 +18,16 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import {
|
|
20
20
|
allFormFields,
|
|
21
|
+
BUILT_IN_FORM_TYPES,
|
|
21
22
|
DEFAULT_UPLOAD_SETTINGS,
|
|
22
23
|
FORM_HONEYPOT_FIELD,
|
|
24
|
+
FORM_PAGE_FIELD,
|
|
23
25
|
FORM_RENDERED_FIELD,
|
|
24
26
|
FORM_RESERVED_FIELDS,
|
|
25
27
|
FORM_TOKEN_FIELD,
|
|
26
28
|
type FormFieldError,
|
|
27
29
|
type FormSubmissionState,
|
|
30
|
+
type FormTypes,
|
|
28
31
|
isUploadAllowed,
|
|
29
32
|
type SchemaForm,
|
|
30
33
|
type SubmittedValues,
|
|
@@ -58,6 +61,8 @@ export interface FormRegistry {
|
|
|
58
61
|
byKey(key: string): SchemaForm | undefined
|
|
59
62
|
/** Drops the memo, for a test or after a schema write. */
|
|
60
63
|
invalidate(): void
|
|
64
|
+
/** The field and workflow types the forms were read with. */
|
|
65
|
+
readonly types: FormTypes
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
/**
|
|
@@ -70,8 +75,9 @@ export interface FormRegistry {
|
|
|
70
75
|
*/
|
|
71
76
|
export function createFormRegistry(
|
|
72
77
|
schemaDir: string,
|
|
73
|
-
options: { ttlMs?: number } = {},
|
|
78
|
+
options: { ttlMs?: number; formTypes?: FormTypes } = {},
|
|
74
79
|
): FormRegistry {
|
|
80
|
+
const types = options.formTypes ?? BUILT_IN_FORM_TYPES
|
|
75
81
|
const ttl = options.ttlMs ?? 1000
|
|
76
82
|
let at = 0
|
|
77
83
|
let forms: readonly SchemaForm[] = []
|
|
@@ -80,7 +86,7 @@ export function createFormRegistry(
|
|
|
80
86
|
const refresh = () => {
|
|
81
87
|
if (at !== 0 && Date.now() - at < ttl) return
|
|
82
88
|
try {
|
|
83
|
-
forms = loadSchemaDirectory(schemaDir).set.forms ?? []
|
|
89
|
+
forms = loadSchemaDirectory(schemaDir, { formTypes: types }).set.forms ?? []
|
|
84
90
|
byKey = new Map(forms.map((form) => [form.key ?? form.alias, form]))
|
|
85
91
|
} catch (error) {
|
|
86
92
|
// A broken schema directory fails the boot, so reaching here means it
|
|
@@ -105,6 +111,7 @@ export function createFormRegistry(
|
|
|
105
111
|
invalidate() {
|
|
106
112
|
at = 0
|
|
107
113
|
},
|
|
114
|
+
types,
|
|
108
115
|
}
|
|
109
116
|
}
|
|
110
117
|
|
|
@@ -206,6 +213,8 @@ export interface FormSubmitDeps {
|
|
|
206
213
|
uploads?: UploadSettings
|
|
207
214
|
/** Resolves a document key to the URL to send the visitor to. */
|
|
208
215
|
urlOf?: (key: string) => Promise<string | undefined>
|
|
216
|
+
/** Fills in the options of choices drawn from a source, as the page rendered them. */
|
|
217
|
+
resolveForm?: (form: SchemaForm) => Promise<SchemaForm>
|
|
209
218
|
}
|
|
210
219
|
|
|
211
220
|
export type FormSubmitOutcome =
|
|
@@ -213,6 +222,8 @@ export type FormSubmitOutcome =
|
|
|
213
222
|
| { status: 'badToken' }
|
|
214
223
|
| { status: 'full'; form: SchemaForm }
|
|
215
224
|
| { status: 'invalid'; form: SchemaForm; state: FormSubmissionState }
|
|
225
|
+
/** One page of a stepped form checked; nothing was stored, uploaded or run. */
|
|
226
|
+
| { status: 'checked'; form: SchemaForm; errors: FormFieldError[] }
|
|
216
227
|
| {
|
|
217
228
|
status: 'ok'
|
|
218
229
|
form: SchemaForm
|
|
@@ -255,8 +266,9 @@ export async function submitForm(
|
|
|
255
266
|
formKey: string,
|
|
256
267
|
options: { ip?: string; now?: number } = {},
|
|
257
268
|
): Promise<FormSubmitOutcome> {
|
|
258
|
-
const
|
|
259
|
-
if (!
|
|
269
|
+
const definition = deps.forms.byKey(formKey)
|
|
270
|
+
if (!definition) return { status: 'notFound' }
|
|
271
|
+
const form = deps.resolveForm ? await deps.resolveForm(definition) : definition
|
|
260
272
|
|
|
261
273
|
const secret = await formSigningSecret(deps.db)
|
|
262
274
|
const data = await request.formData()
|
|
@@ -268,6 +280,10 @@ export async function submitForm(
|
|
|
268
280
|
// submission without a good one did not come from a page this site rendered.
|
|
269
281
|
if (!verifyFormToken(secret, formKey, renderedAt, token)) return { status: 'badToken' }
|
|
270
282
|
|
|
283
|
+
// A stepped form moving forward: this page's answers checked, and nothing else.
|
|
284
|
+
const checking = data.get(FORM_PAGE_FIELD)
|
|
285
|
+
if (checking !== null) return checkPage(deps, form, data, Number(checking))
|
|
286
|
+
|
|
271
287
|
// Two guards, both silent. A bot is told the submission worked, because
|
|
272
288
|
// telling it otherwise is how it learns to get past them.
|
|
273
289
|
const honeypot = String(data.get(FORM_HONEYPOT_FIELD) ?? '').trim() !== ''
|
|
@@ -317,7 +333,7 @@ export async function submitForm(
|
|
|
317
333
|
if (placed.length > 0) submitted[field.alias] = placed
|
|
318
334
|
}
|
|
319
335
|
|
|
320
|
-
const result = validateSubmission(form, submitted)
|
|
336
|
+
const result = validateSubmission(form, submitted, { types: deps.forms.types })
|
|
321
337
|
// What the file rules refused, which core cannot know: it has no notion of a
|
|
322
338
|
// file's bytes or of what a server could be made to execute.
|
|
323
339
|
result.errors.push(...refusedUploads)
|
|
@@ -388,6 +404,47 @@ export async function submitForm(
|
|
|
388
404
|
}
|
|
389
405
|
}
|
|
390
406
|
|
|
407
|
+
/**
|
|
408
|
+
* One page of a stepped form, checked the way the final submission will be.
|
|
409
|
+
* An upload is judged by its name and size and then dropped: it is written
|
|
410
|
+
* nowhere until the form is submitted.
|
|
411
|
+
*/
|
|
412
|
+
function checkPage(
|
|
413
|
+
deps: FormSubmitDeps,
|
|
414
|
+
form: SchemaForm,
|
|
415
|
+
data: FormData,
|
|
416
|
+
page: number,
|
|
417
|
+
): FormSubmitOutcome {
|
|
418
|
+
const fields = form.pages[page]?.groups.flatMap((group) => group.fields)
|
|
419
|
+
if (!fields) return { status: 'checked', form, errors: [] }
|
|
420
|
+
const onPage = new Set(fields.map((field) => field.alias))
|
|
421
|
+
const submitted = readBody(data)
|
|
422
|
+
const errors: FormFieldError[] = []
|
|
423
|
+
for (const field of fields.filter((candidate) => candidate.type === 'fileUpload')) {
|
|
424
|
+
const files = data.getAll(field.alias).filter((value): value is File => value instanceof File)
|
|
425
|
+
const name = field.caption || field.alias
|
|
426
|
+
const named: string[] = []
|
|
427
|
+
for (const file of files) {
|
|
428
|
+
if (file.size === 0) continue
|
|
429
|
+
if (!isUploadAllowed(file.name, deps.uploads ?? DEFAULT_UPLOAD_SETTINGS))
|
|
430
|
+
errors.push({
|
|
431
|
+
field: field.alias,
|
|
432
|
+
message: `${name} cannot be a ${extensionOf(file.name)} file`,
|
|
433
|
+
})
|
|
434
|
+
else if (field.maxSizeKb !== undefined && file.size > field.maxSizeKb * 1024)
|
|
435
|
+
errors.push({
|
|
436
|
+
field: field.alias,
|
|
437
|
+
message: `${name} must be smaller than ${field.maxSizeKb}KB`,
|
|
438
|
+
})
|
|
439
|
+
else named.push(file.name)
|
|
440
|
+
}
|
|
441
|
+
submitted[field.alias] = named
|
|
442
|
+
}
|
|
443
|
+
const result = validateSubmission(form, submitted, { page, types: deps.forms.types })
|
|
444
|
+
errors.push(...result.errors.filter((error) => onPage.has(error.field)))
|
|
445
|
+
return { status: 'checked', form, errors }
|
|
446
|
+
}
|
|
447
|
+
|
|
391
448
|
/** Whether the caller wants JSON rather than a redirect back to the page. */
|
|
392
449
|
export function wantsJson(request: Request): boolean {
|
|
393
450
|
if (request.headers.get('x-requested-with') === 'fetch') return true
|