@crombolo/server 0.14.1 → 0.15.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/package.json +12 -12
- package/src/api-client.ts +47 -0
- package/src/atomic-write.ts +13 -0
- package/src/bundle-install.ts +18 -1
- package/src/config.ts +110 -3
- package/src/database.ts +21 -8
- package/src/domains.ts +5 -4
- package/src/index.ts +5 -0
- package/src/media-store-azure.ts +1 -1
- package/src/media-store.ts +4 -2
- package/src/member-auth.ts +5 -1
- package/src/remote-credentials.ts +108 -0
- package/src/remote-media.ts +91 -0
- package/src/remote.ts +793 -0
- package/src/server.ts +86 -7
- package/src/site-file.ts +45 -11
- package/src/tar.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
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.
|
|
32
|
-
"@crombolo/api-management": "0.
|
|
33
|
-
"@crombolo/assistant": "0.
|
|
34
|
-
"@crombolo/auth": "0.
|
|
35
|
-
"@crombolo/backoffice-host": "0.
|
|
36
|
-
"@crombolo/contracts": "0.
|
|
37
|
-
"@crombolo/core": "0.
|
|
38
|
-
"@crombolo/data": "0.
|
|
39
|
-
"@crombolo/render": "0.
|
|
40
|
-
"@crombolo/schema": "0.
|
|
41
|
-
"@crombolo/transfer": "0.
|
|
31
|
+
"@crombolo/api-delivery": "0.15.0",
|
|
32
|
+
"@crombolo/api-management": "0.15.0",
|
|
33
|
+
"@crombolo/assistant": "0.15.0",
|
|
34
|
+
"@crombolo/auth": "0.15.0",
|
|
35
|
+
"@crombolo/backoffice-host": "0.15.0",
|
|
36
|
+
"@crombolo/contracts": "0.15.0",
|
|
37
|
+
"@crombolo/core": "0.15.0",
|
|
38
|
+
"@crombolo/data": "0.15.0",
|
|
39
|
+
"@crombolo/render": "0.15.0",
|
|
40
|
+
"@crombolo/schema": "0.15.0",
|
|
41
|
+
"@crombolo/transfer": "0.15.0",
|
|
42
42
|
"@logtape/logtape": "^2.3.8"
|
|
43
43
|
}
|
|
44
44
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API client an unattended install seeds (story 034): an API user in the
|
|
3
|
+
* administrators group, holding the configured client, so a pipeline can sign
|
|
4
|
+
* in with the client-credentials grant on a site whose backoffice only takes an
|
|
5
|
+
* identity provider. Created once; a client that exists is never rotated from
|
|
6
|
+
* config. `docs/operations.md`.
|
|
7
|
+
*/
|
|
8
|
+
import { hashPassword } from '@crombolo/auth'
|
|
9
|
+
import { ADMIN_GROUP_ALIAS } from '@crombolo/core'
|
|
10
|
+
import { BUILT_IN_GROUPS, closeSetup, type Db, UserRepository } from '@crombolo/data'
|
|
11
|
+
import { recordAudit } from './adapters/audit.ts'
|
|
12
|
+
|
|
13
|
+
/** Seeds the client when no user holds its id; `existed` when one already does. */
|
|
14
|
+
export async function ensureApiClient(
|
|
15
|
+
db: Db,
|
|
16
|
+
client: { id: string; secret: string },
|
|
17
|
+
): Promise<'created' | 'existed'> {
|
|
18
|
+
const users = new UserRepository(db)
|
|
19
|
+
if (await users.clientCredentialOwner(client.id)) {
|
|
20
|
+
// A machine can already administer the site, so the browser setup step goes.
|
|
21
|
+
await closeSetup(db)
|
|
22
|
+
return 'existed'
|
|
23
|
+
}
|
|
24
|
+
const admins = BUILT_IN_GROUPS.find((group) => group.alias === ADMIN_GROUP_ALIAS)
|
|
25
|
+
const created = await users.create({
|
|
26
|
+
key: crypto.randomUUID(),
|
|
27
|
+
name: 'API client',
|
|
28
|
+
userName: client.id,
|
|
29
|
+
email: '',
|
|
30
|
+
kind: 'Api',
|
|
31
|
+
groupKeys: admins ? [admins.key] : [],
|
|
32
|
+
languageIsoCode: null,
|
|
33
|
+
})
|
|
34
|
+
if (!created) throw new Error(`The API client ${client.id} could not be created.`)
|
|
35
|
+
await users.addClientCredential(created.id, client.id, await hashPassword(client.secret))
|
|
36
|
+
await closeSetup(db)
|
|
37
|
+
await recordAudit(db, {
|
|
38
|
+
category: 'user',
|
|
39
|
+
action: 'Create',
|
|
40
|
+
source: 'cli',
|
|
41
|
+
entityType: 'User',
|
|
42
|
+
entityKey: created.key,
|
|
43
|
+
entityName: client.id,
|
|
44
|
+
detail: 'API client seeded from CROMBOLO_API_CLIENT_ID',
|
|
45
|
+
})
|
|
46
|
+
return 'created'
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes a file whole or not at all: to a temporary file beside it, then a
|
|
3
|
+
* rename, so a crash or a second process never sees half of it.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { dirname } from 'node:path'
|
|
7
|
+
|
|
8
|
+
export function writeFileAtomic(path: string, data: string | Uint8Array): void {
|
|
9
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
10
|
+
const temporary = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`
|
|
11
|
+
writeFileSync(temporary, data)
|
|
12
|
+
renameSync(temporary, path)
|
|
13
|
+
}
|
package/src/bundle-install.ts
CHANGED
|
@@ -524,15 +524,32 @@ export async function installBundleContent(
|
|
|
524
524
|
return refuse([
|
|
525
525
|
'this file was written without its media files (--no-media); pass --allow-missing-media to import the media items without them',
|
|
526
526
|
])
|
|
527
|
+
// Referenced media arrives in the store one file at a time, before the file
|
|
528
|
+
// that names it (story 107): every one has to be there already.
|
|
529
|
+
let referencedPresent = false
|
|
530
|
+
if (manifest.media === 'referenced') {
|
|
531
|
+
const store = await mediaStoreFor(config)
|
|
532
|
+
const absent: string[] = []
|
|
533
|
+
for (const blob of manifest.blobs) if (!(await store.get(blob.key))) absent.push(blob.key)
|
|
534
|
+
if (absent.length > 0 && !input.allowMissingBlobs)
|
|
535
|
+
return refuse([
|
|
536
|
+
`${absent.length} media file(s) this file refers to are not in the media store yet`,
|
|
537
|
+
...absent,
|
|
538
|
+
])
|
|
539
|
+
referencedPresent = true
|
|
540
|
+
}
|
|
527
541
|
if (replace === 'replace-all' && !carriesAdministrator(set.people))
|
|
528
542
|
return refuse([
|
|
529
543
|
'this file carries no back-office administrator, so replacing everything would leave nobody able to sign in; export it with --with-users, or use --replace',
|
|
530
544
|
])
|
|
531
545
|
|
|
532
546
|
// A replace takes the file's side of every difference, and replaces files.
|
|
533
|
-
const
|
|
547
|
+
const replaced: ContentImportInput = replace
|
|
534
548
|
? { ...input, resolveAll: 'take-bundle', replaceFiles: true, publish: input.publish ?? true }
|
|
535
549
|
: input
|
|
550
|
+
const effective: ContentImportInput = referencedPresent
|
|
551
|
+
? { ...replaced, allowMissingBlobs: true }
|
|
552
|
+
: replaced
|
|
536
553
|
|
|
537
554
|
// The coarse layer, under the fine-grained revert: in-place conversion is not
|
|
538
555
|
// reversible by itself, so the same rule as `upgrade` applies here.
|
package/src/config.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { hostname, tmpdir } from 'node:os'
|
|
2
2
|
import { join, resolve } from 'node:path'
|
|
3
3
|
import type { DeliveryApiOptions } from '@crombolo/api-delivery'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_BACKOFFICE_PATH,
|
|
6
|
+
DEFAULT_PASSWORD_CONFIGURATION,
|
|
7
|
+
isValidPassword,
|
|
8
|
+
} from '@crombolo/core'
|
|
5
9
|
import { DEFAULT_RENDER_TIMEOUT_MS, type RedirectRule, type SnapshotLimits } from '@crombolo/render'
|
|
6
10
|
import type { AssistantConfig } from './assistant.ts'
|
|
7
11
|
import type { ServerBundle } from './bundles.ts'
|
|
@@ -30,6 +34,65 @@ export type UserLinkSender = (message: {
|
|
|
30
34
|
message: string | null
|
|
31
35
|
}) => Promise<void>
|
|
32
36
|
|
|
37
|
+
export interface RemoteSettings {
|
|
38
|
+
enabled: boolean
|
|
39
|
+
/** The environment this server is, as the remote protocol names it. */
|
|
40
|
+
environment: string
|
|
41
|
+
/** A push here replaces something people rely on: it needs its own permission. */
|
|
42
|
+
protected: boolean
|
|
43
|
+
/** A push to a protected environment waits for another person's approval. */
|
|
44
|
+
approvals: boolean
|
|
45
|
+
/**
|
|
46
|
+
* The largest push file or media file a client may upload. Every other route
|
|
47
|
+
* keeps `maxRequestBodyBytes`, checked per request, since Bun's own limit is
|
|
48
|
+
* the server's.
|
|
49
|
+
*/
|
|
50
|
+
maxUploadBytes: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The prefix every API client id carries, as the Management API requires. */
|
|
54
|
+
export const API_CLIENT_ID_PREFIX = 'crombolo-back-office-'
|
|
55
|
+
|
|
56
|
+
/** Refuses a seeded API client that the Management API would refuse, naming the variable. */
|
|
57
|
+
function checkApiClient(client: CromboloConfig['apiClient']): void {
|
|
58
|
+
if (!client) return
|
|
59
|
+
if (!client.id || !client.secret)
|
|
60
|
+
throw new Error(
|
|
61
|
+
'CROMBOLO_API_CLIENT_ID and CROMBOLO_API_CLIENT_SECRET go together: set both, or neither.',
|
|
62
|
+
)
|
|
63
|
+
if (
|
|
64
|
+
!client.id.startsWith(API_CLIENT_ID_PREFIX) ||
|
|
65
|
+
client.id.length <= API_CLIENT_ID_PREFIX.length
|
|
66
|
+
)
|
|
67
|
+
throw new Error(`CROMBOLO_API_CLIENT_ID must start with "${API_CLIENT_ID_PREFIX}".`)
|
|
68
|
+
if (!isValidPassword(client.secret))
|
|
69
|
+
throw new Error(
|
|
70
|
+
`CROMBOLO_API_CLIENT_SECRET must be at least ${DEFAULT_PASSWORD_CONFIGURATION.minimumPasswordLength} characters.`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether this site runs on the developer's own machine: development, and an
|
|
76
|
+
* application URL that is unset or names localhost. What `localAdmin` needs.
|
|
77
|
+
*/
|
|
78
|
+
export function runsOnLocalhost(config: CromboloConfig): boolean {
|
|
79
|
+
if (!config.development) return false
|
|
80
|
+
if (!config.applicationUrl) return true
|
|
81
|
+
try {
|
|
82
|
+
const host = new URL(config.applicationUrl).hostname
|
|
83
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1'
|
|
84
|
+
} catch {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The body limit to listen with: raised for remote uploads when the remote is on. */
|
|
90
|
+
export function listeningBodyLimit(config: CromboloConfig): number {
|
|
91
|
+
return config.remote.enabled
|
|
92
|
+
? Math.max(config.maxRequestBodyBytes, config.remote.maxUploadBytes)
|
|
93
|
+
: config.maxRequestBodyBytes
|
|
94
|
+
}
|
|
95
|
+
|
|
33
96
|
export interface CromboloConfig {
|
|
34
97
|
port: number
|
|
35
98
|
/** The largest request body the server will read, media uploads included. */
|
|
@@ -74,6 +137,20 @@ export interface CromboloConfig {
|
|
|
74
137
|
adminPassword: string | undefined
|
|
75
138
|
/** Guards "Set up your new site". Chosen by the owner; never printed, logged or shown. */
|
|
76
139
|
setupCode: string | undefined
|
|
140
|
+
/**
|
|
141
|
+
* The API client an unattended install seeds (story 034), so a pipeline can
|
|
142
|
+
* administer a site whose backoffice only takes an identity provider. Seeded
|
|
143
|
+
* once and never rotated from here; the secret is never printed or logged.
|
|
144
|
+
* With it set, no password administrator is seeded unless `adminPassword` is
|
|
145
|
+
* — or `localAdmin` is, on a site running on localhost.
|
|
146
|
+
*/
|
|
147
|
+
apiClient: { id: string; secret: string } | undefined
|
|
148
|
+
/**
|
|
149
|
+
* Seed the password administrator even with `apiClient` set, so local
|
|
150
|
+
* development keeps printing a sign-in. Honoured only on localhost
|
|
151
|
+
* (`runsOnLocalhost`); ignored, with a warning, anywhere else.
|
|
152
|
+
*/
|
|
153
|
+
localAdmin: boolean
|
|
77
154
|
/** Seals secrets kept in the database, such as a webhook's headers; never stored. */
|
|
78
155
|
secretKey: string | undefined
|
|
79
156
|
/**
|
|
@@ -153,7 +230,7 @@ export interface CromboloConfig {
|
|
|
153
230
|
* CROMBOLO_SCHEMA_WRITABLE says otherwise outright.
|
|
154
231
|
*/
|
|
155
232
|
schemaWritable: boolean
|
|
156
|
-
/** Sync `schema/` at boot; a pipeline that runs `crombolo
|
|
233
|
+
/** Sync `schema/` at boot; a pipeline that runs `crombolo types sync` turns this off. */
|
|
157
234
|
syncSchemaAtBoot: boolean
|
|
158
235
|
/** Which database engine; from CROMBOLO_DB. */
|
|
159
236
|
dialect: 'sqlite' | 'postgres'
|
|
@@ -241,6 +318,15 @@ export interface CromboloConfig {
|
|
|
241
318
|
memberIdentityProviders: readonly IdentityProvider[]
|
|
242
319
|
/** Identity providers backoffice users may sign in with. `allowLocalLogin: false` makes them the only way in. */
|
|
243
320
|
backofficeIdentityProviders: readonly IdentityProvider[]
|
|
321
|
+
/**
|
|
322
|
+
* Answering the remote protocol (story 107): a developer's `crombolo` pulls a
|
|
323
|
+
* copy of this site and pushes work back. Off unless `enabled`. This server
|
|
324
|
+
* offers one environment, itself, named `environment`; `protected` makes a
|
|
325
|
+
* push need the protected-push permission, and `approvals` makes it wait for
|
|
326
|
+
* someone else with that permission to approve it, which needs
|
|
327
|
+
* `CROMBOLO_SECRET_KEY`. docs/remote-protocol.md.
|
|
328
|
+
*/
|
|
329
|
+
remote: RemoteSettings
|
|
244
330
|
/**
|
|
245
331
|
* Redirects the site declares itself, built with `redirect()`. They are synced
|
|
246
332
|
* into the database at boot, so the backoffice lists them beside the tracked
|
|
@@ -427,6 +513,14 @@ export function loadConfig(
|
|
|
427
513
|
sqliteFile: Bun.env.CROMBOLO_SQLITE_FILE ?? DEFAULTS.sqliteFile,
|
|
428
514
|
adminLogin: Bun.env.CROMBOLO_ADMIN_LOGIN ?? DEFAULTS.adminLogin,
|
|
429
515
|
adminPassword: Bun.env.CROMBOLO_ADMIN_PASSWORD,
|
|
516
|
+
apiClient:
|
|
517
|
+
Bun.env.CROMBOLO_API_CLIENT_ID || Bun.env.CROMBOLO_API_CLIENT_SECRET
|
|
518
|
+
? {
|
|
519
|
+
id: Bun.env.CROMBOLO_API_CLIENT_ID ?? '',
|
|
520
|
+
secret: Bun.env.CROMBOLO_API_CLIENT_SECRET ?? '',
|
|
521
|
+
}
|
|
522
|
+
: undefined,
|
|
523
|
+
localAdmin: Bun.env.CROMBOLO_LOCAL_ADMIN === 'true',
|
|
430
524
|
setupCode: Bun.env.CROMBOLO_SETUP_CODE,
|
|
431
525
|
secretKey: Bun.env.CROMBOLO_SECRET_KEY,
|
|
432
526
|
secureCookies: Bun.env.CROMBOLO_INSECURE_COOKIES !== 'true',
|
|
@@ -514,6 +608,13 @@ export function loadConfig(
|
|
|
514
608
|
// No environment variable: a provider is a preset, which is code.
|
|
515
609
|
memberIdentityProviders: [],
|
|
516
610
|
backofficeIdentityProviders: [],
|
|
611
|
+
remote: {
|
|
612
|
+
enabled: Bun.env.CROMBOLO_REMOTE_ENABLED === 'true',
|
|
613
|
+
environment: Bun.env.CROMBOLO_REMOTE_ENVIRONMENT || 'production',
|
|
614
|
+
protected: Bun.env.CROMBOLO_REMOTE_PROTECTED !== 'false',
|
|
615
|
+
approvals: Bun.env.CROMBOLO_REMOTE_APPROVALS === 'true',
|
|
616
|
+
maxUploadBytes: Number(Bun.env.CROMBOLO_REMOTE_MAX_UPLOAD_GB ?? 50) * 1024 ** 3,
|
|
617
|
+
},
|
|
517
618
|
redirects: [],
|
|
518
619
|
trackRedirects: Bun.env.CROMBOLO_TRACK_REDIRECTS !== 'false',
|
|
519
620
|
applyBundleContentOnInstall: Bun.env.CROMBOLO_APPLY_BUNDLE_CONTENT !== 'false',
|
|
@@ -545,6 +646,12 @@ export function loadConfig(
|
|
|
545
646
|
checkIdentityProviders(merged.backofficeIdentityProviders, 'backoffice')
|
|
546
647
|
// Field by field, so a site setting its title suffix keeps the environment's switches.
|
|
547
648
|
merged.seo = { ...fromEnv.seo, ...stripUndefined(overrides.seo ?? {}) }
|
|
649
|
+
merged.remote = { ...fromEnv.remote, ...stripUndefined(overrides.remote ?? {}) }
|
|
650
|
+
checkApiClient(merged.apiClient)
|
|
651
|
+
if (!/^[a-z][a-z0-9-]{0,62}$/.test(merged.remote.environment))
|
|
652
|
+
throw new Error(
|
|
653
|
+
`remote.environment "${merged.remote.environment}" must be lower case letters, digits and dashes.`,
|
|
654
|
+
)
|
|
548
655
|
merged.deliveryApi = {
|
|
549
656
|
...fromEnv.deliveryApi,
|
|
550
657
|
...stripUndefined(overrides.deliveryApi ?? {}),
|
|
@@ -632,4 +739,4 @@ export function describeDatabase(
|
|
|
632
739
|
}
|
|
633
740
|
}
|
|
634
741
|
|
|
635
|
-
export const VERSION = '0.
|
|
742
|
+
export const VERSION = '0.15.0'
|
package/src/database.ts
CHANGED
|
@@ -21,8 +21,10 @@ import {
|
|
|
21
21
|
seedContent,
|
|
22
22
|
seedIdentity,
|
|
23
23
|
} from '@crombolo/data'
|
|
24
|
+
import { ensureApiClient } from './api-client.ts'
|
|
24
25
|
import { type BundleChain, migrateBundles, pendingBundleSteps } from './bundle-tables.ts'
|
|
25
|
-
import type
|
|
26
|
+
import { type CromboloConfig, runsOnLocalhost } from './config.ts'
|
|
27
|
+
import { logger } from './logging.ts'
|
|
26
28
|
|
|
27
29
|
/** Production never auto-upgrades: an existing database with steps pending refuses to boot. */
|
|
28
30
|
export class UpgradePendingError extends Error {
|
|
@@ -127,17 +129,28 @@ export async function installDatabase(
|
|
|
127
129
|
await migrateBundles(db, chains, { by: config.nodeId })
|
|
128
130
|
|
|
129
131
|
// 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
|
-
|
|
132
|
-
|
|
132
|
+
// the owner sets the site up in the browser (docs/operations.md). With an API
|
|
133
|
+
// client to seed (story 034) a machine administers the site instead, and no
|
|
134
|
+
// password administrator exists unless asked for — `adminPassword`, or
|
|
135
|
+
// `localAdmin` on the developer's own machine.
|
|
136
|
+
const localAdmin = config.localAdmin && runsOnLocalhost(config)
|
|
137
|
+
if (config.localAdmin && !localAdmin)
|
|
138
|
+
logger('install').warning(
|
|
139
|
+
'CROMBOLO_LOCAL_ADMIN is ignored: this site does not run on localhost',
|
|
140
|
+
)
|
|
141
|
+
const machineOnly = config.apiClient !== undefined && !config.adminPassword && !localAdmin
|
|
142
|
+
const settingUp = !machineOnly && !config.development && !config.adminPassword
|
|
143
|
+
const password = config.adminPassword || crypto.randomUUID().replaceAll('-', '').slice(0, 20)
|
|
133
144
|
const seeded = await seedIdentity(db, {
|
|
134
|
-
admin:
|
|
135
|
-
|
|
136
|
-
|
|
145
|
+
admin:
|
|
146
|
+
settingUp || machineOnly
|
|
147
|
+
? undefined
|
|
148
|
+
: { name: 'Administrator', login: config.adminLogin, email: config.adminLogin, password },
|
|
137
149
|
hashPassword,
|
|
138
150
|
passwordConfig: passwordConfigJson(),
|
|
139
151
|
})
|
|
140
152
|
if (seeded && settingUp) await markSetupPending(db)
|
|
153
|
+
if (config.apiClient) await ensureApiClient(db, config.apiClient)
|
|
141
154
|
|
|
142
155
|
await seedContent(db)
|
|
143
156
|
// A database from an older version gains the built-ins it lacks.
|
|
@@ -146,5 +159,5 @@ export async function installDatabase(
|
|
|
146
159
|
await ensureSystemMemberTypes(db)
|
|
147
160
|
await ensureSystemContentTypes(db)
|
|
148
161
|
|
|
149
|
-
return seeded && !settingUp && !config.adminPassword ? password : undefined
|
|
162
|
+
return seeded && !settingUp && !machineOnly && !config.adminPassword ? password : undefined
|
|
150
163
|
}
|
package/src/domains.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type NodeRow,
|
|
26
26
|
resolveNodeRef,
|
|
27
27
|
} from '@crombolo/data'
|
|
28
|
+
import { writeFileAtomic } from './atomic-write.ts'
|
|
28
29
|
|
|
29
30
|
export const DOMAINS_FILE = 'domains.toml'
|
|
30
31
|
|
|
@@ -152,8 +153,8 @@ export async function writeDomains(
|
|
|
152
153
|
const file = domainsFilePath(siteDir)
|
|
153
154
|
const backup = join(siteDir, DOMAINS_BACKUP_FILE)
|
|
154
155
|
const kept = existsSync(file) ? readFileSync(file, 'utf8') : undefined
|
|
155
|
-
if (kept !== undefined)
|
|
156
|
-
|
|
156
|
+
if (kept !== undefined) writeFileAtomic(backup, kept)
|
|
157
|
+
writeFileAtomic(file, writeDomainsFile(declarations))
|
|
157
158
|
return { file, backup: kept === undefined ? undefined : backup }
|
|
158
159
|
}
|
|
159
160
|
|
|
@@ -167,10 +168,10 @@ export async function undoDomainsFile(
|
|
|
167
168
|
return { ok: false, file, message: `there is no ${DOMAINS_BACKUP_FILE} to go back to` }
|
|
168
169
|
const previous = readFileSync(backup, 'utf8')
|
|
169
170
|
const current = existsSync(file) ? readFileSync(file, 'utf8') : undefined
|
|
170
|
-
|
|
171
|
+
writeFileAtomic(file, previous)
|
|
171
172
|
// So that undo is itself undoable, which is what somebody who has just undone
|
|
172
173
|
// the wrong thing needs.
|
|
173
|
-
|
|
174
|
+
writeFileAtomic(backup, current ?? '')
|
|
174
175
|
return { ok: true, file }
|
|
175
176
|
}
|
|
176
177
|
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,8 @@ export * from './adapters/public-access.ts'
|
|
|
12
12
|
export * from './adapters/redirects.ts'
|
|
13
13
|
export * from './adapters/references.ts'
|
|
14
14
|
export * from './adapters/template-files.ts'
|
|
15
|
+
export * from './api-client.ts'
|
|
16
|
+
export * from './atomic-write.ts'
|
|
15
17
|
export * from './backup.ts'
|
|
16
18
|
export * from './boot-failure.ts'
|
|
17
19
|
export * from './bundle-install.ts'
|
|
@@ -63,6 +65,9 @@ export * from './no-nodes.ts'
|
|
|
63
65
|
export * from './oembed.ts'
|
|
64
66
|
export * from './ports.ts'
|
|
65
67
|
export * from './redirects.ts'
|
|
68
|
+
export * from './remote.ts'
|
|
69
|
+
export * from './remote-credentials.ts'
|
|
70
|
+
export * from './remote-media.ts'
|
|
66
71
|
export * from './schema.ts'
|
|
67
72
|
export * from './schema-store.ts'
|
|
68
73
|
export * from './secrets.ts'
|
package/src/media-store-azure.ts
CHANGED
|
@@ -168,7 +168,7 @@ export function azureMediaStore(options: AzureMediaStoreOptions): MediaStore {
|
|
|
168
168
|
async put(key, body, putOptions) {
|
|
169
169
|
if (!isSafeMediaKey(key)) throw new Error(`"${key}" is not a usable media key.`)
|
|
170
170
|
// Copied into a plain ArrayBuffer so `fetch` and the signature agree on its length.
|
|
171
|
-
const bytes = new Uint8Array(body instanceof
|
|
171
|
+
const bytes = new Uint8Array(body instanceof Blob ? await body.arrayBuffer() : body)
|
|
172
172
|
const response = await send('PUT', blobUrl(key), {
|
|
173
173
|
body: bytes,
|
|
174
174
|
headers: {
|
package/src/media-store.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* `ab12cd34/photo.jpg` a placed file, served at `/media/ab12cd34/photo.jpg`
|
|
14
14
|
* `.temp/<uuid>/photo.jpg` an upload not yet placed, with `meta.json` beside it
|
|
15
15
|
* `.cache/ab/<hash>.webp` a processed image variant
|
|
16
|
+
* `.remote/<job>/site.tgz` a remote pull's or push's file (story 107), never public
|
|
16
17
|
*
|
|
17
18
|
* A site picks a store in `crombolo.config.ts` — `fileSystemMediaStore`,
|
|
18
19
|
* `s3MediaStore` or `azureMediaStore` — or leaves it to the environment, which
|
|
@@ -41,7 +42,7 @@ export interface MediaStore {
|
|
|
41
42
|
/** Named in the boot banner and in logs: `media/`, `s3://bucket`, … */
|
|
42
43
|
readonly description: string
|
|
43
44
|
get(key: string): Promise<StoredFile | undefined>
|
|
44
|
-
put(key: string, body: Uint8Array |
|
|
45
|
+
put(key: string, body: Uint8Array | Blob, options?: { contentType?: string }): Promise<void>
|
|
45
46
|
delete(key: string): Promise<void>
|
|
46
47
|
/** Every key under a prefix, which is how temporary uploads are swept. */
|
|
47
48
|
list(prefix: string): Promise<string[]>
|
|
@@ -111,7 +112,8 @@ export function isSafeMediaKey(key: string): boolean {
|
|
|
111
112
|
part !== '' &&
|
|
112
113
|
part !== '.' &&
|
|
113
114
|
part !== '..' &&
|
|
114
|
-
(!part.startsWith('.') ||
|
|
115
|
+
(!part.startsWith('.') ||
|
|
116
|
+
(index === 0 && (part === '.temp' || part === '.cache' || part === '.remote'))),
|
|
115
117
|
)
|
|
116
118
|
}
|
|
117
119
|
|
package/src/member-auth.ts
CHANGED
|
@@ -359,7 +359,11 @@ export function createMemberAuth(
|
|
|
359
359
|
// Checked before the password, so the answer never depends on whether the
|
|
360
360
|
// password was right; the same throwaway verify as above keeps the time taken
|
|
361
361
|
// from saying what the response does not.
|
|
362
|
-
|
|
362
|
+
// A member with no password signs in only through a provider (story 035).
|
|
363
|
+
// Refused like a wrong password, in the same time, and not counted: a
|
|
364
|
+
// stranger's guesses must not lock out the provider sign-in, nor say that
|
|
365
|
+
// the account has no password.
|
|
366
|
+
if (member.isLockedOut || !member.isApproved || !member.passwordHash) {
|
|
363
367
|
await verifyPassword(input.password ?? '', await hashPassword(crypto.randomUUID()))
|
|
364
368
|
return failed()
|
|
365
369
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A developer's sign-ins to remotes (story 108): kept in the operating system's
|
|
3
|
+
* keychain (macOS Keychain, Windows Credential Manager, libsecret) through
|
|
4
|
+
* `Bun.secrets`, keyed by the remote's origin, never in the site's folder.
|
|
5
|
+
* Shared by the CLI's commands and a lazily-fetching local media store.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const KEYCHAIN_SERVICE = 'crombolo-remote'
|
|
9
|
+
|
|
10
|
+
export interface Keychain {
|
|
11
|
+
get(name: string): Promise<string | null>
|
|
12
|
+
set(name: string, value: string): Promise<void>
|
|
13
|
+
delete(name: string): Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The operating system's keychain. */
|
|
17
|
+
export function osKeychain(): Keychain {
|
|
18
|
+
return {
|
|
19
|
+
get: (name) => Bun.secrets.get({ service: KEYCHAIN_SERVICE, name }),
|
|
20
|
+
set: (name, value) => Bun.secrets.set({ service: KEYCHAIN_SERVICE, name, value }),
|
|
21
|
+
delete: async (name) => {
|
|
22
|
+
await Bun.secrets.delete({ service: KEYCHAIN_SERVICE, name })
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A keychain in memory, for tests and machines without one. */
|
|
28
|
+
export function memoryKeychain(): Keychain {
|
|
29
|
+
const values = new Map<string, string>()
|
|
30
|
+
return {
|
|
31
|
+
get: async (name) => values.get(name) ?? null,
|
|
32
|
+
set: async (name, value) => {
|
|
33
|
+
values.set(name, value)
|
|
34
|
+
},
|
|
35
|
+
delete: async (name) => {
|
|
36
|
+
values.delete(name)
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RemoteSignIn {
|
|
42
|
+
accessToken: string
|
|
43
|
+
refreshToken: string
|
|
44
|
+
/** Milliseconds since the epoch. */
|
|
45
|
+
expiresAt: number
|
|
46
|
+
tokenUrl: string
|
|
47
|
+
clientId: string
|
|
48
|
+
/** Who approved it, for `whoami`. */
|
|
49
|
+
user?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const keyOf = (origin: string) => new URL(origin).origin
|
|
53
|
+
|
|
54
|
+
export async function readSignIn(
|
|
55
|
+
keychain: Keychain,
|
|
56
|
+
origin: string,
|
|
57
|
+
): Promise<RemoteSignIn | undefined> {
|
|
58
|
+
const stored = await keychain.get(keyOf(origin))
|
|
59
|
+
if (!stored) return undefined
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(stored) as RemoteSignIn
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const saveSignIn = (keychain: Keychain, origin: string, signIn: RemoteSignIn) =>
|
|
68
|
+
keychain.set(keyOf(origin), JSON.stringify(signIn))
|
|
69
|
+
|
|
70
|
+
export const forgetSignIn = (keychain: Keychain, origin: string) => keychain.delete(keyOf(origin))
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A usable access token: the stored one, or a refreshed one when it is within a
|
|
74
|
+
* minute of expiring. Undefined when there is no sign-in, or the remote refused
|
|
75
|
+
* the refresh — the person signs in again.
|
|
76
|
+
*/
|
|
77
|
+
export async function freshAccessToken(
|
|
78
|
+
keychain: Keychain,
|
|
79
|
+
origin: string,
|
|
80
|
+
http: typeof fetch = fetch,
|
|
81
|
+
force = false,
|
|
82
|
+
): Promise<string | undefined> {
|
|
83
|
+
const signIn = await readSignIn(keychain, origin)
|
|
84
|
+
if (!signIn) return undefined
|
|
85
|
+
if (!force && signIn.expiresAt - Date.now() > 60_000) return signIn.accessToken
|
|
86
|
+
const response = await http(signIn.tokenUrl, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
89
|
+
body: new URLSearchParams({
|
|
90
|
+
grant_type: 'refresh_token',
|
|
91
|
+
refresh_token: signIn.refreshToken,
|
|
92
|
+
client_id: signIn.clientId,
|
|
93
|
+
}).toString(),
|
|
94
|
+
})
|
|
95
|
+
if (!response.ok) return undefined
|
|
96
|
+
const tokens = (await response.json()) as {
|
|
97
|
+
access_token: string
|
|
98
|
+
refresh_token: string
|
|
99
|
+
expires_in: number
|
|
100
|
+
}
|
|
101
|
+
await saveSignIn(keychain, origin, {
|
|
102
|
+
...signIn,
|
|
103
|
+
accessToken: tokens.access_token,
|
|
104
|
+
refreshToken: tokens.refresh_token,
|
|
105
|
+
expiresAt: Date.now() + tokens.expires_in * 1000,
|
|
106
|
+
})
|
|
107
|
+
return tokens.access_token
|
|
108
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media fetched from a remote on first request (story 108, `pull
|
|
3
|
+
* --media-info-only --lazy-media`): a local site pulled without its media
|
|
4
|
+
* files reads each one through from the remote it came from the first time it
|
|
5
|
+
* is asked for, under the developer's own sign-in, and keeps it.
|
|
6
|
+
*
|
|
7
|
+
* Only the store the site serves from is wrapped. An export reads the plain
|
|
8
|
+
* store, so pushing a lazily-pulled site never downloads what it never used.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { writeFileAtomic } from './atomic-write.ts'
|
|
13
|
+
import { logger } from './logging.ts'
|
|
14
|
+
import type { MediaStore, StoredFile } from './media-store.ts'
|
|
15
|
+
import { freshAccessToken, type Keychain, osKeychain } from './remote-credentials.ts'
|
|
16
|
+
|
|
17
|
+
export const REMOTE_MEDIA_FILE = join('.crombolo', 'remote-media.json')
|
|
18
|
+
|
|
19
|
+
export interface RemoteMediaSource {
|
|
20
|
+
/** The remote's API base, which is also what its sign-in is kept under. */
|
|
21
|
+
api: string
|
|
22
|
+
environment: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readRemoteMedia(siteDir: string): RemoteMediaSource | undefined {
|
|
26
|
+
const file = join(siteDir, REMOTE_MEDIA_FILE)
|
|
27
|
+
if (!existsSync(file)) return undefined
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<RemoteMediaSource>
|
|
30
|
+
return typeof parsed.api === 'string' && typeof parsed.environment === 'string'
|
|
31
|
+
? { api: parsed.api, environment: parsed.environment }
|
|
32
|
+
: undefined
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function writeRemoteMedia(siteDir: string, source: RemoteMediaSource): void {
|
|
39
|
+
const file = join(siteDir, REMOTE_MEDIA_FILE)
|
|
40
|
+
writeFileAtomic(file, `${JSON.stringify(source, null, 2)}\n`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The site's store, reading through to its remote when the site was pulled lazily. */
|
|
44
|
+
export function withRemoteMedia(
|
|
45
|
+
store: MediaStore,
|
|
46
|
+
siteDir: string,
|
|
47
|
+
options: { keychain?: Keychain; http?: typeof fetch } = {},
|
|
48
|
+
): MediaStore {
|
|
49
|
+
const source = readRemoteMedia(siteDir)
|
|
50
|
+
if (!source) return store
|
|
51
|
+
const keychain = options.keychain ?? osKeychain()
|
|
52
|
+
const http = options.http ?? fetch
|
|
53
|
+
const log = logger('media')
|
|
54
|
+
let warned = false
|
|
55
|
+
|
|
56
|
+
const fetchThrough = async (key: string): Promise<StoredFile | undefined> => {
|
|
57
|
+
const token = await freshAccessToken(keychain, source.api, http)
|
|
58
|
+
if (!token) {
|
|
59
|
+
if (!warned)
|
|
60
|
+
log.warning(
|
|
61
|
+
'A media file is not here yet, and fetching it needs `crombolo login` to {api}',
|
|
62
|
+
{
|
|
63
|
+
api: source.api,
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
warned = true
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
const path = key.split('/').map(encodeURIComponent).join('/')
|
|
70
|
+
const response = await http(`${source.api}/environments/${source.environment}/media/${path}`, {
|
|
71
|
+
headers: { authorization: `Bearer ${token}` },
|
|
72
|
+
}).catch(() => undefined)
|
|
73
|
+
if (!response?.ok) return undefined
|
|
74
|
+
await store.put(key, await response.blob())
|
|
75
|
+
return store.get(key)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
...store,
|
|
80
|
+
description: `${store.description} (fetching from ${new URL(source.api).origin})`,
|
|
81
|
+
async get(key) {
|
|
82
|
+
const local = await store.get(key)
|
|
83
|
+
if (local || key.startsWith('.')) return local
|
|
84
|
+
return fetchThrough(key)
|
|
85
|
+
},
|
|
86
|
+
put: (key, body, putOptions) => store.put(key, body, putOptions),
|
|
87
|
+
delete: (key) => store.delete(key),
|
|
88
|
+
list: (prefix) => store.list(prefix),
|
|
89
|
+
...(store.publicUrl ? { publicUrl: (key: string) => store.publicUrl?.(key) } : {}),
|
|
90
|
+
}
|
|
91
|
+
}
|