@acms-subir/core 0.3.16 → 0.3.17
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/CHANGELOG.md +6 -0
- package/cli/common.mjs +2 -2
- package/cli/main.mjs +10 -3
- package/cli/migrations.generated.mjs +49 -1
- package/cli/remote.mjs +1 -1
- package/cli/sync.mjs +876 -0
- package/docs/environment-sync.md +302 -0
- package/integration.mjs +2 -0
- package/package.json +1 -1
- package/src/components/DesignAccess.tsx +3 -0
- package/src/lib/repo/migrations.ts +2 -0
- package/src/lib/repo/sql.ts +3 -0
- package/src/lib/repo/types.ts +2 -0
- package/src/lib/schema.ts +1 -0
- package/src/lib/sync/auth.ts +24 -0
- package/src/lib/sync/catalog.ts +250 -0
- package/src/lib/sync/migration.ts +51 -0
- package/src/lib/sync/protocol.mjs +125 -0
- package/src/lib/sync/service.ts +994 -0
- package/src/lib/sync/store.ts +349 -0
- package/src/pages/admin/integrations.astro +2 -0
- package/src/pages/api/design/keys.ts +7 -3
- package/src/pages/api/sync/index.ts +114 -0
- package/src/pages/api/sync/media.ts +90 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# Selective environment sync
|
|
2
|
+
|
|
3
|
+
Use `acms sync` to transfer selected native ACMS content between two running
|
|
4
|
+
sites. Both directions work: local → production, production → local, or staging
|
|
5
|
+
→ production. Both sites must run a version that includes sync protocol 1.
|
|
6
|
+
Run `acms sync --help` for the command reference or `acms docs sync` to read
|
|
7
|
+
this guide from an installed package.
|
|
8
|
+
|
|
9
|
+
This transfers content and portable configuration, not application code,
|
|
10
|
+
database schemas, environment variables, or an entire database. Deploy theme
|
|
11
|
+
code, collection definitions and enabled modules through the application's
|
|
12
|
+
normal deployment process first. Secrets remain in each environment's own
|
|
13
|
+
Infisical project/environment/folder. Backups remain the disaster recovery tool.
|
|
14
|
+
|
|
15
|
+
## Connect and choose a direction
|
|
16
|
+
|
|
17
|
+
An owner creates scoped keys under **Design → Access**. The existing design-key
|
|
18
|
+
screen also offers these permissions:
|
|
19
|
+
|
|
20
|
+
| Scope | Permission |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `sync:read` | Read the portable inventory, drafts, media and transfer history |
|
|
23
|
+
| `sync:write` | Create plans, upload media and apply draft transfers; also needs `sync:read` |
|
|
24
|
+
| `sync:publish` | Apply live, publication and rollback plans; also needs both scopes above |
|
|
25
|
+
|
|
26
|
+
Only an owner can issue sync keys. Existing design, agent and booking keys do
|
|
27
|
+
not implicitly grant sync access. For a one-way transfer, the source needs
|
|
28
|
+
read access and the destination needs read/write (plus publish for live work).
|
|
29
|
+
Reverse transfers need the corresponding permissions in the other direction.
|
|
30
|
+
|
|
31
|
+
Supply the keys privately through the named environment variables, preferably
|
|
32
|
+
using the CLI's existing Infisical runtime-injection setup. Do not place tokens
|
|
33
|
+
in command arguments, plan files, source control, or chat.
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
acms site connect http://localhost:4321 --site local --token-env ACMS_LOCAL_TOKEN
|
|
37
|
+
acms site connect https://example.com --site production --token-env ACMS_PRODUCTION_TOKEN
|
|
38
|
+
acms sync init --from local --to production
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`site connect` is an existing command. It stores the URL and environment-variable
|
|
42
|
+
name, not the credential. Remote sites require HTTPS; loopback HTTP is allowed.
|
|
43
|
+
Sync accesses each site's authenticated HTTP API, so the CLI needs network
|
|
44
|
+
access to both sites. It does not open a production database directly.
|
|
45
|
+
|
|
46
|
+
`init` selects a direction and resumes that pair's saved selection. It does not
|
|
47
|
+
transfer content. Switching directions keeps separate selections and baselines:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
acms sync init --from production --to local
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The CLI binds the pair to installation identities as well as URLs. A changed
|
|
54
|
+
profile URL or replaced installation requires a fresh `init`. Two aliases for
|
|
55
|
+
the same installation cannot be synchronized to each other. Cloning the entire
|
|
56
|
+
database also clones its installation identity; use independent installations
|
|
57
|
+
and content transfers rather than using a database clone as a second sync site.
|
|
58
|
+
|
|
59
|
+
## Inspect and select
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
acms sync status
|
|
63
|
+
acms sync status --type pages
|
|
64
|
+
acms sync status --selected
|
|
65
|
+
acms sync status --conflicts
|
|
66
|
+
acms sync list
|
|
67
|
+
acms sync list --type events --calendar community
|
|
68
|
+
acms sync list --search welcome
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Status distinguishes create, update, skip, preserve and conflict. The inventory
|
|
72
|
+
shows references and stable keys. Use the full key when names are ambiguous.
|
|
73
|
+
Posts accept `post:<collection>/<slug>`; `post:<slug>` also works when unique.
|
|
74
|
+
Forms and places have names rather than slugs, so quote references containing
|
|
75
|
+
spaces, such as `'form:Contact us'`.
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
acms sync add page:home page:about
|
|
79
|
+
acms sync add post:posts/welcome
|
|
80
|
+
acms sync add event:open-house
|
|
81
|
+
acms sync add resource:meeting-room
|
|
82
|
+
acms sync add 'form:Contact us'
|
|
83
|
+
acms sync add calendar:community --include-events
|
|
84
|
+
acms sync add --type navigation
|
|
85
|
+
acms sync add --type booking-config
|
|
86
|
+
acms sync add --all
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`add --all` selects eligible changed source items, including conflicts for
|
|
90
|
+
review. It never selects customer bookings, submissions, account credentials,
|
|
91
|
+
secrets or deletions. `add --type` selects changed items in that category.
|
|
92
|
+
Selecting a calendar alone does not select all its events; `--include-events`
|
|
93
|
+
does. Selected events include the calendars and other referenced objects they
|
|
94
|
+
need. Pages include referenced media and native ID-based dependencies. Links
|
|
95
|
+
to arbitrary URLs are not crawled or copied.
|
|
96
|
+
|
|
97
|
+
`acms sync select` is a terminal picker: it displays numbered items and accepts
|
|
98
|
+
a comma-separated selection. Blank input preserves the selection. Explicit
|
|
99
|
+
commands provide the equivalent workflow for scripts; interactive mode cannot
|
|
100
|
+
be combined with `--json`.
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
acms sync remove page:test-landing
|
|
104
|
+
acms sync remove --type events
|
|
105
|
+
acms sync clear
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Removing items only changes the pending selection. An explicitly removed
|
|
109
|
+
dependency blocks its parent rather than silently reselecting it. Add the
|
|
110
|
+
dependency again, preserve an existing destination version, or remove the parent.
|
|
111
|
+
`clear` clears selections, exclusions and conflict decisions, not history or
|
|
112
|
+
the last successful synchronization baseline.
|
|
113
|
+
|
|
114
|
+
## Review differences and conflicts
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
acms sync diff
|
|
118
|
+
acms sync diff page:home
|
|
119
|
+
acms sync diff --selected
|
|
120
|
+
acms sync resolve page:home --use source
|
|
121
|
+
acms sync resolve page:home --use destination
|
|
122
|
+
acms sync resolve page:home --interactive
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Diff shows actual before/after field values. The source inventory includes
|
|
126
|
+
saved pending drafts as the editable source version; it does not capture
|
|
127
|
+
unsaved browser edits. Live versus draft behavior on the destination is chosen
|
|
128
|
+
when planning.
|
|
129
|
+
|
|
130
|
+
The CLI remembers the last successfully applied source and destination hashes:
|
|
131
|
+
|
|
132
|
+
| Comparison | Default action |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| Same content on both sites | Skip |
|
|
135
|
+
| Only the source changed since the baseline | Update |
|
|
136
|
+
| Only the destination changed | Preserve destination |
|
|
137
|
+
| Both changed differently | Conflict |
|
|
138
|
+
| Existing item with no established baseline | Review before overwrite |
|
|
139
|
+
| Previously transferred item deleted on destination | Conflict |
|
|
140
|
+
| Item exists only on destination | Preserve |
|
|
141
|
+
|
|
142
|
+
Matching uses recorded identities first. A unique natural-key candidate
|
|
143
|
+
(such as a collection and slug) can be proposed for the initial match, but a
|
|
144
|
+
differing existing item requires explicit resolution. Ambiguous names are
|
|
145
|
+
blocked; fix ambiguity on the destination first. Slug changes keep an established
|
|
146
|
+
identity. Sync does not automatically create redirects for renamed URLs;
|
|
147
|
+
select the appropriate redirect records as well.
|
|
148
|
+
|
|
149
|
+
`--use source` approves the source version; `--use destination` leaves the
|
|
150
|
+
destination content intact. If a previously transferred item was deleted on
|
|
151
|
+
the destination, `--use source` explicitly approves recreating it. Required
|
|
152
|
+
preserved dependencies are linked without overwriting their data. Interactive
|
|
153
|
+
resolution chooses whole top-level fields;
|
|
154
|
+
it is not a text-editor or automatic rich-text/array merge. Conflict decisions
|
|
155
|
+
are tied to the reviewed source and destination versions and expire when either
|
|
156
|
+
changes. There is no `--force` bypass.
|
|
157
|
+
|
|
158
|
+
## Plan, apply and publish
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
acms sync plan
|
|
162
|
+
acms sync plan --mode draft --out deployment-plan.json
|
|
163
|
+
acms sync plan --mode live
|
|
164
|
+
acms sync show <plan-id>
|
|
165
|
+
acms sync apply --plan <plan-id>
|
|
166
|
+
acms sync apply --file deployment-plan.json
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Draft is the default. Pages, posts, events, forms and design support draft
|
|
170
|
+
transfers. New draft-capable records are created unpublished; existing live
|
|
171
|
+
content retains its live version while changes are staged. New required media
|
|
172
|
+
and tag records are added to their libraries. Updating existing media/tag
|
|
173
|
+
metadata and transferring navigation, calendars, resources, booking links,
|
|
174
|
+
places, redirects or portable settings requires an explicit live plan. A
|
|
175
|
+
published form's definition can be staged; its name/kind/purpose changes need
|
|
176
|
+
a live plan. An event with room holds must have those configured on the
|
|
177
|
+
destination after transfer.
|
|
178
|
+
|
|
179
|
+
Plans list selected operations, dependencies, field differences and whether
|
|
180
|
+
changes affect drafts or live content. They are stored on the destination and
|
|
181
|
+
bound to its installation ID, revision and exact payload. Planning changes
|
|
182
|
+
transfer metadata only, never site content. Exported files include a human-
|
|
183
|
+
readable copy, source/destination binding and a digest; editing their payload
|
|
184
|
+
does not alter the immutable server plan. Create a new plan to change it.
|
|
185
|
+
|
|
186
|
+
Apply displays the plan and asks for confirmation. Automation uses:
|
|
187
|
+
|
|
188
|
+
```sh
|
|
189
|
+
acms sync apply --plan <plan-id> --yes --json
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`--yes` skips only the confirmation. It cannot bypass scopes, dependencies,
|
|
193
|
+
schema/theme compatibility, conflicts or stale revisions. Apply does not need
|
|
194
|
+
the current selection to match the saved plan; it applies that exact plan.
|
|
195
|
+
|
|
196
|
+
The database checks the reviewed revision inside the same transaction as all
|
|
197
|
+
record writes, identity links, search-index changes and the completed-run
|
|
198
|
+
receipt. Content/draft changes through other APIs also advance the revision.
|
|
199
|
+
The first implementation uses a conservative **site-wide revision**: unrelated
|
|
200
|
+
content edits or booking/submission activity after planning can require a new
|
|
201
|
+
plan too. This favors rejecting stale plans over overwriting concurrent work.
|
|
202
|
+
|
|
203
|
+
Media is uploaded before that transaction to immutable checksum-based keys.
|
|
204
|
+
Already present verified objects are skipped. Failed uploads or a rejected
|
|
205
|
+
database transaction can leave unused files, but do not partially publish the
|
|
206
|
+
database changes. Sync does not delete these files automatically.
|
|
207
|
+
|
|
208
|
+
If a request or terminal is interrupted, rerun the same apply command. A
|
|
209
|
+
completed server receipt is returned without repeating writes; otherwise
|
|
210
|
+
verified files are reused and the database transaction can be retried if the
|
|
211
|
+
reviewed revision still matches. There is no blind retry of stale writes.
|
|
212
|
+
|
|
213
|
+
After reviewing staged content in the site's editors/previews:
|
|
214
|
+
|
|
215
|
+
```sh
|
|
216
|
+
acms sync plan --publish <run-id>
|
|
217
|
+
acms sync apply --plan <publish-plan-id>
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
This publishes only the draft-capable items in that run. It rejects later edits
|
|
221
|
+
to those items instead of implicitly publishing somebody else's changes.
|
|
222
|
+
Design/layout changes conservatively share a settings guard.
|
|
223
|
+
|
|
224
|
+
## History and rollback
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
acms sync history
|
|
228
|
+
acms sync show <run-id>
|
|
229
|
+
acms sync plan --rollback <run-id>
|
|
230
|
+
acms sync apply --plan <rollback-plan-id>
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
History lists the latest 100 plans and runs. An older known ID can still be
|
|
234
|
+
read with `show`. Rollback creates another reviewable live plan. It restores
|
|
235
|
+
previous live/draft values for changed items and can delete items that the run
|
|
236
|
+
created, provided they have not subsequently changed or acquired dependents or
|
|
237
|
+
production activity. Media bytes are retained, even if their newly created
|
|
238
|
+
library record is removed. Later design/settings edits may conservatively block
|
|
239
|
+
rollback of a page layout. Rollback does not undo delivered emails, webhook
|
|
240
|
+
deliveries, external effects or code deployments.
|
|
241
|
+
|
|
242
|
+
## Transfer coverage
|
|
243
|
+
|
|
244
|
+
| Category | Included | Environment-specific data preserved |
|
|
245
|
+
| --- | --- | --- |
|
|
246
|
+
| `pages` | Native rich text, legacy blocks, translations, layout, SEO and routing fields | Unselected pages |
|
|
247
|
+
| `posts` | Native content, translations, metadata and byline references | Staff credentials and account authority |
|
|
248
|
+
| `design` | Theme selection, design overrides and branding images | Theme source code; page layouts travel with pages |
|
|
249
|
+
| `navigation` | Individual navigation records | Other destination navigation records |
|
|
250
|
+
| `media` | Metadata, originals and variants, with checksums | Unselected assets |
|
|
251
|
+
| `redirects` | Selected redirect records | Other destination redirects |
|
|
252
|
+
| `calendars` | Calendar definitions | Feed tokens and source URLs |
|
|
253
|
+
| `events` | Native events and registration-form definitions | Registrations and notification history |
|
|
254
|
+
| `resources` | Resource configuration | Private busy-feed URLs, notification recipients and feed tokens |
|
|
255
|
+
| `booking-config` | Booking-link definitions and calendar/booking defaults | Reservations, retry ledgers and private management links |
|
|
256
|
+
| `forms` | Definitions/templates, native drafts | Submitted responses |
|
|
257
|
+
| `site-settings` | Title, tagline, footer text, locale, timezone, announcement | Canonical site URL, integration/analytics configuration, injected code, secrets |
|
|
258
|
+
| `places` | Saved place definitions | Other saved places |
|
|
259
|
+
| `tags` | Tag metadata | Other tag records |
|
|
260
|
+
|
|
261
|
+
Byline dependencies map to existing destination profiles. Sync never creates
|
|
262
|
+
or edits user accounts; provision the intended profile separately and preserve
|
|
263
|
+
its destination version when appropriate. Feed calendars must already exist
|
|
264
|
+
on the destination with their own configured URL. Calendar and booking modules,
|
|
265
|
+
post collections, theme code and section definitions must be deployed before
|
|
266
|
+
their associated content can be applied.
|
|
267
|
+
|
|
268
|
+
Events with existing destination reservations and resources with active
|
|
269
|
+
reservations cannot be overwritten through sync. Use the destination editor
|
|
270
|
+
for those operational changes. Bookings and submissions created after review
|
|
271
|
+
invalidate the plan; rollback also checks for activity before deleting items.
|
|
272
|
+
|
|
273
|
+
## Automation, storage and limits
|
|
274
|
+
|
|
275
|
+
- `--json` returns the existing `acms.cli/1` envelope; exit 4 denotes conflicts,
|
|
276
|
+
3 authentication/permission failures, 5 network/server failures, and 2 invalid
|
|
277
|
+
usage/input. Diagnostics and interactive prompts use stderr.
|
|
278
|
+
- `--dry-run` on selection commands does not save selection changes. On a
|
|
279
|
+
normal plan it asks the server to validate without storing a plan; on apply
|
|
280
|
+
it displays the saved plan without uploading or applying. Publication and
|
|
281
|
+
rollback plans are also validated without storing them in dry-run mode.
|
|
282
|
+
- `--cwd <project>` chooses the project workspace. Selection, baselines and
|
|
283
|
+
exported-plan references are stored under
|
|
284
|
+
`${ACMS_CONFIG_DIR:-~/.config/acms}/sync/<workspace-hash>/`, with private file
|
|
285
|
+
permissions. They are not committed into the project. Exported plan files
|
|
286
|
+
contain unpublished content and should be kept out of source control.
|
|
287
|
+
- A workspace lock prevents simultaneous CLI commands from losing selections.
|
|
288
|
+
After a crashed CLI process, verify that the PID in the reported lock file
|
|
289
|
+
is no longer running before removing that file.
|
|
290
|
+
- Protocol 1 supports up to 2,000 inventory items, an 8 MiB inventory, 200
|
|
291
|
+
selected operations per plan and 16 MiB per media object. Oversized inputs
|
|
292
|
+
fail explicitly; split selections where possible. The inventory is bounded
|
|
293
|
+
rather than silently truncated. This version hashes media during inventory
|
|
294
|
+
reads; large media libraries may need a future paginated/cached inventory.
|
|
295
|
+
Hosting-platform request, subrequest and CPU limits can impose lower practical
|
|
296
|
+
limits; local D1 tests do not establish a live Cloudflare account's limits.
|
|
297
|
+
- Sync history lives on the destination; it is not part of portable content
|
|
298
|
+
backups. Sync does not promise a cross-resource transaction with object
|
|
299
|
+
storage. Keep normal off-site backups and test disaster recovery separately.
|
|
300
|
+
|
|
301
|
+
There is no automatic deletion propagation, background two-way synchronization,
|
|
302
|
+
stash command, automatic text merge, or full database transfer.
|
package/integration.mjs
CHANGED
|
@@ -87,6 +87,8 @@ const ROUTES = [
|
|
|
87
87
|
['/api/ai/clean', 'api/ai/clean.ts'],
|
|
88
88
|
['/api/ai/draft', 'api/ai/draft.ts'],
|
|
89
89
|
['/api/design', 'api/design/index.ts'],
|
|
90
|
+
['/api/sync', 'api/sync/index.ts'],
|
|
91
|
+
['/api/sync/media', 'api/sync/media.ts'],
|
|
90
92
|
['/api/design/keys', 'api/design/keys.ts'],
|
|
91
93
|
['/api/design/capabilities', 'api/design/capabilities.ts'],
|
|
92
94
|
['/api/design/media', 'api/design/media.ts'],
|
package/package.json
CHANGED
|
@@ -12,6 +12,9 @@ const SCOPES: { key: string; label: string; hint: string }[] = [
|
|
|
12
12
|
{ key: 'design:publish', label: 'Publish design', hint: 'Makes the draft live for visitors. Only grant this to tools you trust' },
|
|
13
13
|
{ key: 'media:read', label: 'Read media', hint: 'Browse the media library' },
|
|
14
14
|
{ key: 'media:write', label: 'Upload media', hint: 'Add images to the media library' },
|
|
15
|
+
{ key: 'sync:read', label: 'Read environment content', hint: 'Read portable content, media and sync history, including drafts' },
|
|
16
|
+
{ key: 'sync:write', label: 'Stage environment transfers', hint: 'Create plans, upload assets and apply draft transfers; also needs Read environment content' },
|
|
17
|
+
{ key: 'sync:publish', label: 'Apply live environment transfers', hint: 'Publish content and apply reviewed live or rollback plans; also needs both sync permissions above' },
|
|
15
18
|
];
|
|
16
19
|
const DEFAULT_SCOPES = ['design:read', 'design:write-draft'];
|
|
17
20
|
|
|
@@ -7,6 +7,7 @@ import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
|
|
|
7
7
|
import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
|
|
8
8
|
import { PLACES_MIGRATION } from '../maps/migration';
|
|
9
9
|
import { ROLES_MIGRATION } from '../permissions/migration';
|
|
10
|
+
import { SYNC_MIGRATION } from '../sync/migration';
|
|
10
11
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
11
12
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
12
13
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -632,6 +633,7 @@ export const MIGRATIONS: Migration[] = [
|
|
|
632
633
|
`INSERT INTO backup_state (id) VALUES (1)`,
|
|
633
634
|
] },
|
|
634
635
|
ROLES_MIGRATION,
|
|
636
|
+
SYNC_MIGRATION,
|
|
635
637
|
];
|
|
636
638
|
|
|
637
639
|
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AUTH } from '../auth/config';
|
|
2
|
+
import { SyncStore } from '../sync/store';
|
|
2
3
|
import { AuthStore } from '../auth/store';
|
|
3
4
|
import { FormStore, formInsert, submissionInsert } from '../forms/store';
|
|
4
5
|
import { GuestStore } from '../mobile/guest-store';
|
|
@@ -260,6 +261,7 @@ function safeSnippet(raw: unknown): string {
|
|
|
260
261
|
}
|
|
261
262
|
|
|
262
263
|
export class SqlRepository implements ContentRepository {
|
|
264
|
+
readonly sync: SyncStore;
|
|
263
265
|
private driver!: SqlDriver;
|
|
264
266
|
private ready: Promise<void>;
|
|
265
267
|
readonly auth: AuthStore;
|
|
@@ -274,6 +276,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
274
276
|
|
|
275
277
|
constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
276
278
|
this.ready = this.init(driver);
|
|
279
|
+
this.sync = new SyncStore(async () => { await this.ready; return this.driver; });
|
|
277
280
|
this.forms = new FormStore(async () => { await this.ready; return this.driver; });
|
|
278
281
|
this.guests = new GuestStore(async () => { await this.ready; return this.driver; });
|
|
279
282
|
this.live = new LiveStore(async () => { await this.ready; return this.driver; });
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AuthStore } from '../auth/store';
|
|
2
|
+
import type { SyncStore } from '../sync/store';
|
|
2
3
|
import type { FormStore } from '../forms/store';
|
|
3
4
|
import type { BackupStore } from '../backup/store';
|
|
4
5
|
import type { GuestStore } from '../mobile/guest-store';
|
|
@@ -53,6 +54,7 @@ export type EventUpdate = Partial<
|
|
|
53
54
|
// The single contract both backends implement. The app only ever imports
|
|
54
55
|
// this — never a concrete adapter.
|
|
55
56
|
export interface ContentRepository {
|
|
57
|
+
readonly sync: SyncStore;
|
|
56
58
|
readonly auth: AuthStore;
|
|
57
59
|
readonly email: EmailStore;
|
|
58
60
|
readonly forms: FormStore;
|
package/src/lib/schema.ts
CHANGED
|
@@ -637,6 +637,7 @@ export const API_SCOPES = [
|
|
|
637
637
|
'bookings:read', 'bookings:write',
|
|
638
638
|
'resources:read', 'resources:write',
|
|
639
639
|
'design:read', 'design:write-draft', 'design:publish', 'media:read', 'media:write',
|
|
640
|
+
'sync:read', 'sync:write', 'sync:publish',
|
|
640
641
|
] as const;
|
|
641
642
|
export const ApiScope = z.enum(API_SCOPES);
|
|
642
643
|
export type ApiScope = z.infer<typeof ApiScope>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ApiError } from "../api";
|
|
2
|
+
import { hashAgentKey } from "../ai-config";
|
|
3
|
+
import type { ContentRepository } from "../repo/types";
|
|
4
|
+
export const SYNC_SCOPES = ["sync:read", "sync:write", "sync:publish"] as const;
|
|
5
|
+
export async function syncIdentity(
|
|
6
|
+
request: Request,
|
|
7
|
+
repo: ContentRepository,
|
|
8
|
+
required: readonly string[],
|
|
9
|
+
) {
|
|
10
|
+
const origin = request.headers.get("origin");
|
|
11
|
+
if (
|
|
12
|
+
request.headers.get("sec-fetch-site") === "cross-site" ||
|
|
13
|
+
(origin && origin !== new URL(request.url).origin)
|
|
14
|
+
)
|
|
15
|
+
throw new ApiError(403, "Cross-origin sync requests are not allowed.");
|
|
16
|
+
const header = request.headers.get("authorization") ?? "";
|
|
17
|
+
if (!header.startsWith("Bearer "))
|
|
18
|
+
throw new ApiError(401, "Use a scoped sync key.");
|
|
19
|
+
const key = await repo.findApiKeyByHash(hashAgentKey(header.slice(7)));
|
|
20
|
+
if (!key) throw new ApiError(401, "Invalid sync key.");
|
|
21
|
+
if (required.some((s) => !key.scopes.includes(s as any)))
|
|
22
|
+
throw new ApiError(403, `Required scopes: ${required.join(", ")}`);
|
|
23
|
+
return { actor: `key:${key.id}`, scopes: key.scopes };
|
|
24
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
Page,
|
|
4
|
+
Post,
|
|
5
|
+
Calendar,
|
|
6
|
+
CalEvent,
|
|
7
|
+
Resource,
|
|
8
|
+
BookingLink,
|
|
9
|
+
Place,
|
|
10
|
+
Media,
|
|
11
|
+
NavItem,
|
|
12
|
+
Redirect,
|
|
13
|
+
Tag,
|
|
14
|
+
Settings,
|
|
15
|
+
CalendarSettings,
|
|
16
|
+
} from "../schema";
|
|
17
|
+
import { SavedForm } from "../forms/schema";
|
|
18
|
+
import { DesignPatch } from "../design-service";
|
|
19
|
+
import { SectionPages } from "../../theme-sdk/contract.mjs";
|
|
20
|
+
import { pickPageDraft, pickPostDraft, pickEventDraft } from "../drafts";
|
|
21
|
+
import { ApiError } from "../api";
|
|
22
|
+
|
|
23
|
+
export type Data = Record<string, any>;
|
|
24
|
+
const common = { id: true, createdAt: true, updatedAt: true } as const;
|
|
25
|
+
export const schemas = {
|
|
26
|
+
page: Page.omit({ ...common, draft: true })
|
|
27
|
+
.extend({ layout: z.unknown().nullable() })
|
|
28
|
+
.strict(),
|
|
29
|
+
post: Post.omit({ ...common, draft: true, authorId: true }).strict(),
|
|
30
|
+
calendar: Calendar.omit({
|
|
31
|
+
...common,
|
|
32
|
+
feedToken: true,
|
|
33
|
+
feedUrl: true,
|
|
34
|
+
}).strict(),
|
|
35
|
+
event: CalEvent.omit({
|
|
36
|
+
...common,
|
|
37
|
+
draft: true,
|
|
38
|
+
startAt: true,
|
|
39
|
+
endAt: true,
|
|
40
|
+
sourceUid: true,
|
|
41
|
+
}).strict(),
|
|
42
|
+
resource: Resource.omit({
|
|
43
|
+
...common,
|
|
44
|
+
feedToken: true,
|
|
45
|
+
busyFeeds: true,
|
|
46
|
+
notifyEmails: true,
|
|
47
|
+
}).strict(),
|
|
48
|
+
booking: BookingLink.omit(common).strict(),
|
|
49
|
+
place: Place.omit(common).strict(),
|
|
50
|
+
form: SavedForm.omit({ ...common, draft: true, revision: true }).strict(),
|
|
51
|
+
media: Media.omit({ id: true, createdAt: true })
|
|
52
|
+
.extend({
|
|
53
|
+
objects: z
|
|
54
|
+
.array(
|
|
55
|
+
z
|
|
56
|
+
.object({
|
|
57
|
+
key: z.string(),
|
|
58
|
+
digest: z.string().regex(/^[a-f0-9]{64}$/),
|
|
59
|
+
size: z.number().int().nonnegative(),
|
|
60
|
+
})
|
|
61
|
+
.strict(),
|
|
62
|
+
)
|
|
63
|
+
.max(20),
|
|
64
|
+
})
|
|
65
|
+
.strict(),
|
|
66
|
+
nav: NavItem.omit({ id: true }).strict(),
|
|
67
|
+
redirect: Redirect.omit({ id: true, createdAt: true }).strict(),
|
|
68
|
+
tag: Tag.omit({ id: true }).strict(),
|
|
69
|
+
design: DesignPatch.omit({ sectionPages: true, navItems: true }).strict(),
|
|
70
|
+
settings: Settings.pick({
|
|
71
|
+
siteTitle: true,
|
|
72
|
+
tagline: true,
|
|
73
|
+
footerText: true,
|
|
74
|
+
timezone: true,
|
|
75
|
+
locale: true,
|
|
76
|
+
announcement: true,
|
|
77
|
+
}).strict(),
|
|
78
|
+
"booking-settings": CalendarSettings.strict(),
|
|
79
|
+
// Bylines can be linked to existing profiles; accounts never transfer.
|
|
80
|
+
author: z.object({ name: z.string(), slug: z.string() }).strict(),
|
|
81
|
+
};
|
|
82
|
+
export type Kind = keyof typeof schemas;
|
|
83
|
+
export const tables: Partial<Record<Kind, string>> = {
|
|
84
|
+
page: "pages",
|
|
85
|
+
post: "posts",
|
|
86
|
+
calendar: "calendars",
|
|
87
|
+
event: "events",
|
|
88
|
+
resource: "resources",
|
|
89
|
+
booking: "booking_links",
|
|
90
|
+
place: "places",
|
|
91
|
+
form: "forms",
|
|
92
|
+
media: "media",
|
|
93
|
+
nav: "nav_items",
|
|
94
|
+
redirect: "redirects",
|
|
95
|
+
tag: "tags",
|
|
96
|
+
author: "users",
|
|
97
|
+
};
|
|
98
|
+
export const settingsKinds = new Set<Kind>([
|
|
99
|
+
"design",
|
|
100
|
+
"settings",
|
|
101
|
+
"booking-settings",
|
|
102
|
+
]);
|
|
103
|
+
export const draftKinds = new Set<Kind>([
|
|
104
|
+
"page",
|
|
105
|
+
"post",
|
|
106
|
+
"event",
|
|
107
|
+
"form",
|
|
108
|
+
"design",
|
|
109
|
+
]);
|
|
110
|
+
const special: Record<string, string> = {
|
|
111
|
+
order: "ord",
|
|
112
|
+
start: "starts",
|
|
113
|
+
end: "ends",
|
|
114
|
+
};
|
|
115
|
+
export const column = (field: string) =>
|
|
116
|
+
special[field] ?? field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
117
|
+
const jsonFields = new Set([
|
|
118
|
+
"blocks",
|
|
119
|
+
"translations",
|
|
120
|
+
"tags",
|
|
121
|
+
"calendarIds",
|
|
122
|
+
"fields",
|
|
123
|
+
"location",
|
|
124
|
+
"cta",
|
|
125
|
+
"rsvp",
|
|
126
|
+
"roomHold",
|
|
127
|
+
"exdates",
|
|
128
|
+
"amenities",
|
|
129
|
+
"hours",
|
|
130
|
+
"overrides",
|
|
131
|
+
"lengths",
|
|
132
|
+
"approvalRules",
|
|
133
|
+
"questions",
|
|
134
|
+
"variants",
|
|
135
|
+
"definition",
|
|
136
|
+
"designOverrides",
|
|
137
|
+
"announcement",
|
|
138
|
+
]);
|
|
139
|
+
const boolFields = new Set([
|
|
140
|
+
"isHome",
|
|
141
|
+
"featured",
|
|
142
|
+
"allDay",
|
|
143
|
+
"cancelled",
|
|
144
|
+
"showOnCalendar",
|
|
145
|
+
"isDefault",
|
|
146
|
+
"autoConfirm",
|
|
147
|
+
"askPhone",
|
|
148
|
+
"active",
|
|
149
|
+
]);
|
|
150
|
+
export function fields(kind: Kind) {
|
|
151
|
+
return Object.keys(schemas[kind].shape).filter(
|
|
152
|
+
(k) => !["layout", "objects", "authorIds"].includes(k),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
export const encode = (value: any) =>
|
|
156
|
+
value === null
|
|
157
|
+
? null
|
|
158
|
+
: typeof value === "boolean"
|
|
159
|
+
? Number(value)
|
|
160
|
+
: typeof value === "object"
|
|
161
|
+
? JSON.stringify(value)
|
|
162
|
+
: value;
|
|
163
|
+
export function rowData(kind: Kind, row: Data): Data {
|
|
164
|
+
const data: Data = {};
|
|
165
|
+
for (const key of fields(kind)) {
|
|
166
|
+
let value = row[column(key)];
|
|
167
|
+
if (value === undefined) continue;
|
|
168
|
+
if (jsonFields.has(key) && typeof value === "string")
|
|
169
|
+
value = JSON.parse(value);
|
|
170
|
+
if (boolFields.has(key)) value = Boolean(value);
|
|
171
|
+
data[key] = value;
|
|
172
|
+
}
|
|
173
|
+
return data;
|
|
174
|
+
}
|
|
175
|
+
export function parseData(kind: Kind, input: unknown): Data {
|
|
176
|
+
const data = schemas[kind].parse(input) as Data;
|
|
177
|
+
if (kind === "page" && data.layout !== null)
|
|
178
|
+
SectionPages.parse({ page: data.layout });
|
|
179
|
+
if (
|
|
180
|
+
["page", "post", "calendar", "event", "resource", "booking"].includes(
|
|
181
|
+
kind,
|
|
182
|
+
) &&
|
|
183
|
+
!/^[a-z0-9][a-z0-9-]{0,199}$/.test(data.slug)
|
|
184
|
+
)
|
|
185
|
+
throw new ApiError(400, "Sync requires a valid content slug.");
|
|
186
|
+
if (kind === "media") {
|
|
187
|
+
for (const key of [
|
|
188
|
+
data.key,
|
|
189
|
+
...data.variants.map((v: Data) => v.key),
|
|
190
|
+
...data.objects.map((o: Data) => o.key),
|
|
191
|
+
])
|
|
192
|
+
safeMediaKey(key);
|
|
193
|
+
const required = [data.key, ...data.variants.map((v: Data) => v.key)];
|
|
194
|
+
if (
|
|
195
|
+
data.objects.length !== required.length ||
|
|
196
|
+
required.some(
|
|
197
|
+
(k) => data.objects.filter((o: Data) => o.key === k).length !== 1,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
throw new ApiError(
|
|
201
|
+
400,
|
|
202
|
+
"Media checksums must cover each file exactly once.",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return data;
|
|
206
|
+
}
|
|
207
|
+
export function safeMediaKey(key: string) {
|
|
208
|
+
if (
|
|
209
|
+
!/^[a-zA-Z0-9_./-]+$/.test(key) ||
|
|
210
|
+
key.startsWith("/") ||
|
|
211
|
+
key.split("/").some((p) => !p || p === "." || p === "..") ||
|
|
212
|
+
key.length > 500
|
|
213
|
+
)
|
|
214
|
+
throw new ApiError(400, "Invalid media storage key.");
|
|
215
|
+
return key;
|
|
216
|
+
}
|
|
217
|
+
export function effective(kind: Kind, live: Data, draft: Data | null): Data {
|
|
218
|
+
if (!draft) return live;
|
|
219
|
+
const pick =
|
|
220
|
+
kind === "page"
|
|
221
|
+
? pickPageDraft
|
|
222
|
+
: kind === "post"
|
|
223
|
+
? pickPostDraft
|
|
224
|
+
: kind === "event"
|
|
225
|
+
? pickEventDraft
|
|
226
|
+
: null;
|
|
227
|
+
if (pick) return { ...live, ...pick(draft) };
|
|
228
|
+
if (kind === "form") return { ...live, definition: draft };
|
|
229
|
+
if (kind === "design")
|
|
230
|
+
return {
|
|
231
|
+
...live,
|
|
232
|
+
...Object.fromEntries(
|
|
233
|
+
Object.entries(draft).filter(([k]) => fields("design").includes(k)),
|
|
234
|
+
),
|
|
235
|
+
};
|
|
236
|
+
return live;
|
|
237
|
+
}
|
|
238
|
+
export function matchKey(kind: Kind, data: Data) {
|
|
239
|
+
if (kind === "post") return `${data.collection}/${data.slug}`;
|
|
240
|
+
if (kind === "booking") return `${data.resourceId}/${data.slug}`;
|
|
241
|
+
if (kind === "nav") return `${data.area}/${data.url}`;
|
|
242
|
+
if (kind === "redirect") return data.fromPath;
|
|
243
|
+
if (kind === "media") return data.objects?.[0]?.digest ?? data.key;
|
|
244
|
+
return data.slug || data.name || kind;
|
|
245
|
+
}
|
|
246
|
+
export function displayRef(kind: Kind, data: Data) {
|
|
247
|
+
if (settingsKinds.has(kind)) return `${kind}:site`;
|
|
248
|
+
if (kind === "post") return `post:${data.collection}/${data.slug}`;
|
|
249
|
+
return `${kind}:${data.slug || data.name || data.filename || data.fromPath || data.label}`;
|
|
250
|
+
}
|