@crombolo/server 0.13.2 → 0.14.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/server",
3
- "version": "0.13.2",
3
+ "version": "0.14.1",
4
4
  "description": "The crombolo HTTP server: routing, configuration, media stores and background jobs.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,17 +28,17 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/api-delivery": "0.13.2",
32
- "@crombolo/api-management": "0.13.2",
33
- "@crombolo/assistant": "0.13.2",
34
- "@crombolo/auth": "0.13.2",
35
- "@crombolo/backoffice-host": "0.13.2",
36
- "@crombolo/contracts": "0.13.2",
37
- "@crombolo/core": "0.13.2",
38
- "@crombolo/data": "0.13.2",
39
- "@crombolo/render": "0.13.2",
40
- "@crombolo/schema": "0.13.2",
41
- "@crombolo/transfer": "0.13.2",
31
+ "@crombolo/api-delivery": "0.14.1",
32
+ "@crombolo/api-management": "0.14.1",
33
+ "@crombolo/assistant": "0.14.1",
34
+ "@crombolo/auth": "0.14.1",
35
+ "@crombolo/backoffice-host": "0.14.1",
36
+ "@crombolo/contracts": "0.14.1",
37
+ "@crombolo/core": "0.14.1",
38
+ "@crombolo/data": "0.14.1",
39
+ "@crombolo/render": "0.14.1",
40
+ "@crombolo/schema": "0.14.1",
41
+ "@crombolo/transfer": "0.14.1",
42
42
  "@logtape/logtape": "^2.3.8"
43
43
  }
44
44
  }
@@ -8,7 +8,6 @@
8
8
  * items as the `.udt` the dictionary already exports. The build is composition,
9
9
  * which is why a bundle can carry everything the builder UI offers.
10
10
  */
11
- import { join } from 'node:path'
12
11
  import {
13
12
  type BuiltPackage,
14
13
  type FileSystemPort,
@@ -23,31 +22,15 @@ import {
23
22
  type Bundle,
24
23
  BundleRepository,
25
24
  type ComponentFileStore,
26
- ComponentRepository,
27
- currentSchemaState,
28
25
  type Db,
29
- DictionaryRepository,
30
26
  type NodeRow,
31
27
  resolveNodeRef,
32
28
  } from '@crombolo/data'
33
29
  import {
34
- exportSchemaSet,
35
- fileNameFor,
36
- type SchemaSet,
37
- writeContentType,
38
- writeDataType,
39
- writeLanguages,
40
- writeMediaType,
41
- writeSchemaVersion,
42
- } from '@crombolo/schema'
43
- import {
44
- BUNDLE_SECTIONS,
45
30
  type BundleBlob,
46
31
  type BundleDependency,
47
- type BundleFile,
48
32
  type BundleKind,
49
33
  type BundleNode,
50
- type BundleSection,
51
34
  bundleIdFor,
52
35
  bundlePeerRange,
53
36
  type ContentSet,
@@ -58,9 +41,9 @@ import {
58
41
  writeBundlePackage,
59
42
  } from '@crombolo/transfer'
60
43
  import { VERSION } from '../config.ts'
61
- import { dictionaryToUdt, writeDictionaryUdt } from '../dictionary-transfer.ts'
62
44
  import { readBlobs } from '../media-files.ts'
63
45
  import type { MediaStore } from '../media-store.ts'
46
+ import { siteSections } from '../site-sections.ts'
64
47
  import { writeNpmTarball } from '../tar.ts'
65
48
 
66
49
  /** The npm package a definition is built as: what was set on it, or what its name gives. */
@@ -119,48 +102,6 @@ const stored = <T extends PackageDefinitionInput>(input: T) => ({
119
102
  components: [...input.templates, ...input.partialViews],
120
103
  })
121
104
 
122
- /** Matches a selection against a type's key or its alias, since the UI sends either. */
123
- const picks = (selected: readonly string[]) => {
124
- const wanted = new Set(selected.map((value) => value.trim().toLowerCase()))
125
- return (type: { key?: string; alias: string }): boolean =>
126
- wanted.has(type.alias.toLowerCase()) ||
127
- (type.key !== undefined && wanted.has(type.key.toLowerCase()))
128
- }
129
-
130
- /** The selected slice of the site's schema, as the files `schema/` would hold. */
131
- function schemaEntries(set: SchemaSet, from: PackageDefinition): BundleFile[] {
132
- const entries: BundleFile[] = []
133
- const put = (path: string, text: string) =>
134
- entries.push({ path: `${BUNDLE_SECTIONS.schema}/${path}`, text })
135
-
136
- const contentTypes = set.contentTypes.filter(picks(from.contentTypes))
137
- const mediaTypes = (set.mediaTypes ?? []).filter(picks(from.mediaTypes))
138
- // Member types have no picker in the builder, and the contract's definition
139
- // has no field for them, so a bundle never carries one.
140
- const dataTypes = set.dataTypes.filter(picks(from.dataTypes))
141
- const languages = set.languages.filter((language) =>
142
- from.languages.some((pick) => pick.toLowerCase() === language.iso.toLowerCase()),
143
- )
144
-
145
- if (
146
- contentTypes.length === 0 &&
147
- mediaTypes.length === 0 &&
148
- dataTypes.length === 0 &&
149
- languages.length === 0
150
- )
151
- return entries
152
-
153
- put('schema.toml', writeSchemaVersion(set.version))
154
- for (const type of contentTypes)
155
- put(join('content-types', fileNameFor(type.alias)), writeContentType(type))
156
- for (const type of mediaTypes)
157
- put(join('media-types', fileNameFor(type.alias)), writeMediaType(type))
158
- for (const type of dataTypes)
159
- put(join('data-types', fileNameFor(type.alias)), writeDataType(type))
160
- if (languages.length > 0) put('languages.toml', writeLanguages(languages))
161
- return entries
162
- }
163
-
164
105
  /**
165
106
  * Several exports as one bundle: the nodes deduped by key, the counts summed,
166
107
  * and the dependencies and selector unioned, so the manifest describes what the
@@ -227,73 +168,6 @@ export function createPackagePort(db: Db, options: PackagePortOptions): PackageP
227
168
  const validate = (input: PackageDefinitionInput): PackageWriteResult | undefined =>
228
169
  input.name.length === 0 ? { ok: false, status: 'InvalidName' } : undefined
229
170
 
230
- /**
231
- * The files a selection of file-system paths resolves to, under one section.
232
- *
233
- * The section is a logical name — `styles`, not `css` — because a site holds
234
- * these wherever it is configured to, and the importer is what maps a section
235
- * onto a directory.
236
- */
237
- const fileEntries = async (
238
- port: FileSystemPort | undefined,
239
- paths: readonly string[],
240
- section: BundleSection,
241
- ): Promise<BundleFile[]> => {
242
- if (!port) return []
243
- const files: BundleFile[] = []
244
- for (const path of paths) {
245
- const file = await port.read(path)
246
- if (!file) continue
247
- const relative = file.path.replace(/^\/+/, '')
248
- files.push({ path: `${BUNDLE_SECTIONS[section]}/${relative}`, text: file.content })
249
- }
250
- return files
251
- }
252
-
253
- const componentEntries = async (keys: readonly string[]): Promise<BundleFile[]> => {
254
- if (keys.length === 0) return []
255
- const templates = new ComponentRepository(db, options.componentFiles)
256
- const all = await templates.all()
257
- const wanted = new Set(keys.map((key) => key.trim().toLowerCase()))
258
- const files: BundleFile[] = []
259
- for (const template of all) {
260
- if (!wanted.has(template.key.toLowerCase()) && !wanted.has(template.alias.toLowerCase()))
261
- continue
262
- files.push({
263
- path: `${BUNDLE_SECTIONS.components}/${template.alias}.tsx`,
264
- text: template.content ?? '',
265
- })
266
- }
267
- return files
268
- }
269
-
270
- /**
271
- * The components a bundle carries, from one list that holds two kinds of
272
- * name.
273
- *
274
- * The contract has `templates` and `partialViews`; this side has one list, so
275
- * a picked component arrives either as a template alias — a row in
276
- * `template`, whose file is read through the alias — or as a path in the
277
- * components tree. Both are tried and the results are keyed by path, so a
278
- * component picked both ways is carried once.
279
- */
280
- const componentSection = async (picked: readonly string[]): Promise<BundleFile[]> => {
281
- const byPath = new Map<string, BundleFile>()
282
- for (const file of await componentEntries(picked)) byPath.set(file.path, file)
283
- for (const file of await fileEntries(options.components, picked, 'components'))
284
- byPath.set(file.path, file)
285
- return [...byPath.values()]
286
- }
287
-
288
- const dictionaryEntries = async (keys: readonly string[]): Promise<BundleFile[]> => {
289
- if (keys.length === 0) return []
290
- const repo = new DictionaryRepository(db)
291
- const items = []
292
- for (const key of keys) items.push(...(await dictionaryToUdt(repo, key)))
293
- if (items.length === 0) return []
294
- return [{ path: `${BUNDLE_SECTIONS.dictionary}.udt`, text: writeDictionaryUdt(items) }]
295
- }
296
-
297
171
  /**
298
172
  * The content, media and element picks as one transfer bundle, blobs included.
299
173
  *
@@ -404,18 +278,25 @@ export function createPackagePort(db: Db, options: PackagePortOptions): PackageP
404
278
  const from = definition(row)
405
279
  const pkg = bundlePackageOf(row)
406
280
 
407
- // The schema version the site is actually at, so the files a package
408
- // carries say where they came from rather than claiming version 0.
409
- const state = await currentSchemaState(db).catch(() => undefined)
410
- const schema = await exportSchemaSet(db, state?.version ?? '0')
411
-
412
- const sections: BundleFile[] = [
413
- ...schemaEntries(schema, from),
414
- ...(await componentSection([...from.templates, ...from.partialViews])),
415
- ...(await fileEntries(options.stylesheets, from.stylesheets, 'styles')),
416
- ...(await fileEntries(options.scripts, from.scripts, 'scripts')),
417
- ...(await dictionaryEntries(from.dictionaryItems)),
418
- ]
281
+ const sections = await siteSections(
282
+ {
283
+ db,
284
+ componentFiles: options.componentFiles,
285
+ components: options.components,
286
+ stylesheets: options.stylesheets,
287
+ scripts: options.scripts,
288
+ },
289
+ {
290
+ contentTypes: from.contentTypes,
291
+ mediaTypes: from.mediaTypes,
292
+ dataTypes: from.dataTypes,
293
+ languages: from.languages,
294
+ components: [...from.templates, ...from.partialViews],
295
+ stylesheets: from.stylesheets,
296
+ scripts: from.scripts,
297
+ dictionaryItems: from.dictionaryItems,
298
+ },
299
+ )
419
300
 
420
301
  const content = await contentEntries(from)
421
302
  // A package with nothing picked is still a bundle: an empty selection
@@ -12,9 +12,9 @@
12
12
  * - **Passwords.** Plaintext arrives here and nowhere deeper; a change of one
13
13
  * rotates the security stamp, which ends the member's sessions.
14
14
  *
15
- * `kind` is always `Default`: Umbraco reports `Api` only for a Delivery API
16
- * client-credentials member and `ExternalOnly` only for one in its lightweight
17
- * external-member table, and we have neither.
15
+ * `kind` is `Api` for a Delivery API client-credentials member, `ExternalOnly`
16
+ * for one with no password, who signs in only through an identity provider,
17
+ * and `Default` otherwise.
18
18
  */
19
19
  import type {
20
20
  MemberFilterCriteria,
@@ -37,6 +37,7 @@ import {
37
37
  type DocumentRepositoryOptions,
38
38
  type MemberCredentials,
39
39
  MemberRepository,
40
+ TwoFactorRepository,
40
41
  type ValueIntake,
41
42
  WriteRejectedError,
42
43
  } from '@crombolo/data'
@@ -60,10 +61,18 @@ const notFound = () => ({
60
61
 
61
62
  export function createMemberPort(db: Db, options: MemberPortOptions = {}): MemberPort {
62
63
  const apiMembers = new Set((options.apiMemberUserNames ?? []).map((n) => n.toLowerCase()))
63
- const kindOf = (userName: string): 'Api' | 'Default' =>
64
- apiMembers.has(userName.toLowerCase()) ? 'Api' : 'Default'
64
+ // A member with no password of their own signs in only through an identity
65
+ // provider (story 031): Umbraco's `ExternalOnly`, which the workspace shows
66
+ // with the username read-only and the password optional.
67
+ const kindOf = (member: MemberCredentials): 'Api' | 'ExternalOnly' | 'Default' =>
68
+ apiMembers.has(member.username.toLowerCase())
69
+ ? 'Api'
70
+ : member.passwordHash
71
+ ? 'Default'
72
+ : 'ExternalOnly'
65
73
  const members = new MemberRepository(db, options)
66
74
  const memberTypes = new ContentTypeRepository(db, { kind: 'member' })
75
+ const twoFactor = new TwoFactorRepository(db)
67
76
 
68
77
  const userIdOf = async (principal: Principal): Promise<number | undefined> => {
69
78
  const rows = await db.query<{ id: number }>('SELECT id FROM user_account WHERE key = ?', [
@@ -111,14 +120,13 @@ export function createMemberPort(db: Db, options: MemberPortOptions = {}): Membe
111
120
  },
112
121
  isApproved: member.isApproved,
113
122
  isLockedOut: member.isLockedOut,
114
- // No second factor is offered yet, so no member has one enabled.
115
- isTwoFactorEnabled: false,
123
+ isTwoFactorEnabled: (await twoFactor.providers(member.key)).length > 0,
116
124
  failedPasswordAttempts: member.failedPasswordAttempts,
117
125
  lastLoginDate: member.lastLoginDate?.toISOString() ?? null,
118
126
  lastLockoutDate: member.lastLockoutDate?.toISOString() ?? null,
119
127
  lastPasswordChangeDate: member.lastPasswordChangeDate?.toISOString() ?? null,
120
128
  groups: await members.groupKeys(member.key),
121
- kind: kindOf(member.username),
129
+ kind: kindOf(member),
122
130
  profileData: null,
123
131
  flags: [],
124
132
  values: content.values
@@ -144,7 +152,7 @@ export function createMemberPort(db: Db, options: MemberPortOptions = {}): Membe
144
152
  id: member.key,
145
153
  memberType: { id: member.contentTypeKey, icon: member.contentTypeIcon ?? 'icon-user' },
146
154
  variants: [{ name: member.name, culture: null }],
147
- kind: kindOf(member.username),
155
+ kind: kindOf(member),
148
156
  flags: [],
149
157
  })
150
158
 
@@ -239,7 +247,11 @@ export function createMemberPort(db: Db, options: MemberPortOptions = {}): Membe
239
247
  groupKeys: input.groupKeys,
240
248
  },
241
249
  )
242
- return updated ? { ok: true as const } : notFound()
250
+ if (!updated) return notFound()
251
+ // The workspace's two-factor toggle can only be turned off: an
252
+ // administrator's reset for a member who lost their phone (story 032).
253
+ if (!input.isTwoFactorEnabled) await twoFactor.disable(key)
254
+ return { ok: true as const }
243
255
  } catch (error) {
244
256
  return failure(error)
245
257
  }
@@ -36,6 +36,7 @@ import type { CromboloConfig } from '../config.ts'
36
36
  import { formTypesFor } from '../form-types.ts'
37
37
  import { logger } from '../logging.ts'
38
38
  import { componentAliasesIn, importSchema } from '../schema.ts'
39
+ import { publishSchema, useSchemaStore } from '../schema-store.ts'
39
40
 
40
41
  const log = logger('schema')
41
42
 
@@ -124,6 +125,9 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
124
125
  return { ok: false, error: problems.map((problem) => problem.message).join('; ') }
125
126
  }
126
127
 
128
+ // Fresh from the store first, then written, imported and published without
129
+ // materialising again, which would lay the store's copy over this write.
130
+ await useSchemaStore(config)
127
131
  const path = pathFor(kind, alias)
128
132
  const versionFile = join(config.schemaDir, 'schema.toml')
129
133
  const previous = existsSync(path) ? await readFile(path, 'utf8') : undefined
@@ -143,14 +147,17 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
143
147
  // is nothing left to compare. The version moves in the same breath, or the
144
148
  // import lands at a version the other nodes already have and they never
145
149
  // learn they are behind.
146
- const ahead = await importSchema(options.db, config, options.boot, { dryRun: true })
150
+ const ahead = await importSchema(options.db, config, options.boot, {
151
+ dryRun: true,
152
+ materialise: false,
153
+ })
147
154
  const { version, urls } = loadSchemaDirectory(config.schemaDir, {
148
155
  formTypes: formTypesFor(config),
149
156
  }).set
150
157
  const next = nextSchemaVersion(version, ahead.classification)
151
158
  if (next !== version) await writeFile(versionFile, writeSchemaVersion(next, urls), 'utf8')
152
159
 
153
- const result = await importSchema(options.db, config, options.boot)
160
+ const result = await importSchema(options.db, config, options.boot, { materialise: false })
154
161
  if (result.report && result.report.action !== 'applied') {
155
162
  // Put the files back: a refused import leaves the site with a file it will
156
163
  // not accept, which is worse than the change not having happened.
@@ -160,6 +167,7 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
160
167
  error: result.report.reason ?? `the import was ${result.report.action}`,
161
168
  }
162
169
  }
170
+ if (config.schemaStore) await publishSchema(config.schemaStore, config.schemaDir)
163
171
  await options.afterSync?.(result.report)
164
172
  log.info('{alias} applied from a proposal: {classification}, schema at {version}', {
165
173
  alias,
@@ -41,6 +41,8 @@ import {
41
41
  import {
42
42
  type Db,
43
43
  DbDate,
44
+ ExternalLoginRepository,
45
+ externalLoginProvider,
44
46
  type GranularPermission,
45
47
  SUPER_USER_KEY,
46
48
  UserDataRepository,
@@ -106,7 +108,12 @@ type Tree = 'document' | 'media' | 'element'
106
108
  export interface UserPortOptions {
107
109
  config: Pick<
108
110
  CromboloConfig,
109
- 'usernameIsEmail' | 'applicationUrl' | 'sendUserLink' | 'allowPasswordReset' | 'backOfficePath'
111
+ | 'usernameIsEmail'
112
+ | 'applicationUrl'
113
+ | 'sendUserLink'
114
+ | 'allowPasswordReset'
115
+ | 'backOfficePath'
116
+ | 'backofficeIdentityProviders'
110
117
  >
111
118
  mediaFiles: MediaFileStore
112
119
  nodes: NodeLookup
@@ -276,6 +283,21 @@ export function createUserPorts(
276
283
  }),
277
284
  passwordConfiguration: () => DEFAULT_PASSWORD_CONFIGURATION,
278
285
 
286
+ // Linking happens by signing in through the provider; the profile only lists it.
287
+ async loginProviders(principal) {
288
+ const linked = await new ExternalLoginRepository(db).forOwner(principal.id)
289
+ return config.backofficeIdentityProviders.map((provider) => {
290
+ const scheme = externalLoginProvider('user', provider.oidc.id)
291
+ const link = linked.find((l) => l.loginProvider.toLowerCase() === scheme.toLowerCase())
292
+ return {
293
+ providerSchemeName: scheme,
294
+ providerKey: link?.providerKey ?? null,
295
+ isLinkedOnUser: Boolean(link),
296
+ hasManualLinkingEnabled: false,
297
+ }
298
+ })
299
+ },
300
+
279
301
  async list(principal, skip, take) {
280
302
  const admin = await adminKey()
281
303
  const visible = (await users.all()).filter(await visibleTo(principal))
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Backoffice users signing in through an identity provider (story 031): the
3
+ * user side of `external-sign-in.ts`. A sign-in ends in the same session a
4
+ * password sign-in does, so the authorize endpoint carries on from there.
5
+ */
6
+ import type { AuthService } from '@crombolo/auth'
7
+ import { type Db, UserGroupRepository, type UserRecord, UserRepository } from '@crombolo/data'
8
+ import type { ExternalAccount, ExternalSignInAudience } from './external-sign-in.ts'
9
+ import type { IdentityProvider } from './identity-providers.ts'
10
+
11
+ /** Where backoffice sign-ins through a provider answer, under the security path the login uses. */
12
+ export const BACKOFFICE_EXTERNAL_PATH = '/umbraco/management/api/v1/security/back-office/external'
13
+
14
+ export function backofficeAudience(
15
+ db: Db,
16
+ options: {
17
+ auth: AuthService
18
+ providers: readonly IdentityProvider[]
19
+ backOfficePath: string
20
+ /** The `Set-Cookie` value for a new backoffice session. */
21
+ sessionCookie(sessionId: string): string
22
+ clientAddress?(request: Request): string | undefined
23
+ /** Called once a user is signed in, for the audit log. */
24
+ onSignIn?(user: UserRecord, request: Request): Promise<void>
25
+ },
26
+ ): ExternalSignInAudience {
27
+ const users = new UserRepository(db)
28
+ const groups = new UserGroupRepository(db)
29
+
30
+ const aliasesOf = async (user: UserRecord) => {
31
+ const found = await groups.byKeys(user.groupKeys)
32
+ return found.map((g) => g.alias)
33
+ }
34
+ const keysOf = async (aliases: readonly string[]) => {
35
+ const all = await groups.all()
36
+ return aliases.flatMap((alias) => {
37
+ const group = all.find((g) => g.alias.toLowerCase() === alias.toLowerCase())
38
+ return group ? [group.key] : []
39
+ })
40
+ }
41
+ const asAccount = async (user: UserRecord): Promise<ExternalAccount> => ({
42
+ key: user.key,
43
+ email: user.email,
44
+ groups: await aliasesOf(user),
45
+ })
46
+
47
+ return {
48
+ audience: 'user',
49
+ providers: options.providers,
50
+ basePath: BACKOFFICE_EXTERNAL_PATH,
51
+ byKey: async (key) => {
52
+ const user = await users.byKey(key)
53
+ return user ? asAccount(user) : undefined
54
+ },
55
+ byEmail: async (email) => {
56
+ const user = await users.byEmail(email)
57
+ return user ? asAccount(user) : undefined
58
+ },
59
+ create: async (identity, provider, groupAliases) => {
60
+ const email = identity.email as string
61
+ const userName = (await users.byUserName(email))
62
+ ? `${provider.oidc.id}-${identity.subject}`
63
+ : email
64
+ const created = await users.create({
65
+ key: crypto.randomUUID(),
66
+ name: identity.name || email,
67
+ userName,
68
+ email,
69
+ kind: 'Default',
70
+ groupKeys: await keysOf(groupAliases),
71
+ languageIsoCode: null,
72
+ })
73
+ if (!created) throw new Error(`A user for ${email} could not be created.`)
74
+ return asAccount(created)
75
+ },
76
+ setGroups: async (account, groupAliases) => {
77
+ await users.setGroups([account.key], await keysOf(groupAliases))
78
+ },
79
+ signIn: async (account, request) => {
80
+ const user = await users.byKey(account.key)
81
+ if (!user) return { refused: 'This account no longer exists.' }
82
+ const result = await options.auth.signInExternally(
83
+ user.userName,
84
+ options.clientAddress?.(request),
85
+ )
86
+ if (result.status !== 'success')
87
+ return {
88
+ refused:
89
+ result.reason === 'lockedOut'
90
+ ? 'This account is locked out.'
91
+ : 'This account is not allowed to sign in yet.',
92
+ }
93
+ await options.onSignIn?.(user, request)
94
+ return { cookies: [options.sessionCookie(result.sessionId)] }
95
+ },
96
+ failed: (returnUrl, reason) =>
97
+ `${options.backOfficePath}/login?returnUrl=${encodeURIComponent(returnUrl)}&externalLoginError=${encodeURIComponent(reason)}`,
98
+ }
99
+ }