includio-cms 0.22.0 → 0.24.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/API.md +1 -1
- package/CHANGELOG.md +42 -0
- package/DOCS.md +1169 -146
- package/ROADMAP.md +5 -330
- package/dist/core/server/entries/operations/get.bench.d.ts +1 -0
- package/dist/core/server/entries/operations/get.bench.js +68 -0
- package/dist/core/server/entries/operations/get.js +17 -7
- package/dist/core/server/fields/utils/imageStyles.bench.d.ts +1 -0
- package/dist/core/server/fields/utils/imageStyles.bench.js +82 -0
- package/dist/core/server/fields/utils/imageStyles.js +49 -53
- package/dist/core/server/media/operations/backgroundMaintenance.d.ts +6 -0
- package/dist/core/server/media/operations/backgroundMaintenance.js +6 -1
- package/dist/core/server/media/styles/operations/getImageStyle.d.ts +7 -0
- package/dist/core/server/media/styles/operations/getImageStyle.js +24 -0
- package/dist/db-postgres/index.d.ts +1 -1
- package/dist/db-postgres/index.js +27 -0
- package/dist/paraglide/messages/_index.d.ts +36 -3
- package/dist/paraglide/messages/_index.js +71 -3
- package/dist/paraglide/messages/en.d.ts +5 -0
- package/dist/paraglide/messages/en.js +14 -0
- package/dist/paraglide/messages/pl.d.ts +5 -0
- package/dist/paraglide/messages/pl.js +14 -0
- package/dist/types/adapters/db.d.ts +16 -0
- package/dist/types/adapters/db.js +8 -1
- package/dist/updates/0.23.0/index.d.ts +2 -0
- package/dist/updates/0.23.0/index.js +21 -0
- package/dist/updates/0.24.0/index.d.ts +2 -0
- package/dist/updates/0.24.0/index.js +20 -0
- package/dist/updates/index.js +3 -1
- package/package.json +2 -1
- package/dist/paraglide/messages/hello_world.d.ts +0 -5
- package/dist/paraglide/messages/hello_world.js +0 -33
- package/dist/paraglide/messages/login_hello.d.ts +0 -16
- package/dist/paraglide/messages/login_hello.js +0 -34
- package/dist/paraglide/messages/login_please_login.d.ts +0 -16
- package/dist/paraglide/messages/login_please_login.js +0 -34
package/DOCS.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Includio CMS Documentation (v0.
|
|
1
|
+
# Includio CMS Documentation (v0.24.0)
|
|
2
2
|
|
|
3
3
|
> This file is auto-generated from the docs site. For the latest version, update the package.
|
|
4
4
|
|
|
@@ -789,7 +789,7 @@ Entries are the content records stored in your database. Both collections and si
|
|
|
789
789
|
|
|
790
790
|
### Populated Entry (API output)
|
|
791
791
|
|
|
792
|
-
When fetching entries via `
|
|
792
|
+
When fetching entries via `resolveEntry` / `resolveEntries`, you receive populated entries with metadata prefixed by `_`:
|
|
793
793
|
|
|
794
794
|
```typescript
|
|
795
795
|
type Entry = {
|
|
@@ -861,87 +861,77 @@ Only one version per status per language can exist at a time.
|
|
|
861
861
|
## Querying
|
|
862
862
|
|
|
863
863
|
```typescript
|
|
864
|
-
import {
|
|
864
|
+
import { resolveEntry, resolveEntries, countEntries } from 'includio-cms/admin/remote';
|
|
865
865
|
|
|
866
|
-
//
|
|
867
|
-
const posts = await
|
|
868
|
-
|
|
869
|
-
status: 'published',
|
|
870
|
-
language: 'en'
|
|
871
|
-
});
|
|
872
|
-
|
|
873
|
-
// Get a single entry
|
|
874
|
-
const homepage = await getEntry({
|
|
875
|
-
slug: 'homepage',
|
|
866
|
+
// Published entries for a collection
|
|
867
|
+
const posts = await resolveEntries({
|
|
868
|
+
collection: 'posts',
|
|
876
869
|
status: 'published',
|
|
877
|
-
|
|
878
|
-
});
|
|
879
|
-
|
|
880
|
-
// Filter by exact field values
|
|
881
|
-
const featured = await getEntries({
|
|
882
|
-
slug: 'posts',
|
|
883
|
-
dataValues: { featured: true }
|
|
870
|
+
locale: 'en'
|
|
884
871
|
});
|
|
885
872
|
|
|
886
|
-
//
|
|
887
|
-
const
|
|
888
|
-
slug: 'posts',
|
|
889
|
-
dataLike: { title: 'svelte' }
|
|
890
|
-
});
|
|
873
|
+
// Single entry by ID
|
|
874
|
+
const post = await resolveEntry({ id: 'uuid', locale: 'en' });
|
|
891
875
|
|
|
892
|
-
//
|
|
893
|
-
const
|
|
894
|
-
|
|
895
|
-
|
|
876
|
+
// Singleton (treated as a one-entry collection)
|
|
877
|
+
const homepage = await resolveEntry({
|
|
878
|
+
collection: 'homepage',
|
|
879
|
+
status: 'published',
|
|
880
|
+
locale: 'en'
|
|
896
881
|
});
|
|
897
882
|
|
|
898
|
-
//
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
language: 'en',
|
|
903
|
-
dataOrderBy: { field: 'eventDate', direction: 'asc' }
|
|
883
|
+
// Filter by IDs
|
|
884
|
+
const someOf = await resolveEntries({
|
|
885
|
+
collection: 'posts',
|
|
886
|
+
ids: ['id-1', 'id-2', 'id-3']
|
|
904
887
|
});
|
|
905
888
|
|
|
906
889
|
// Pagination
|
|
907
|
-
const page = await
|
|
908
|
-
|
|
890
|
+
const page = await resolveEntries({
|
|
891
|
+
collection: 'posts',
|
|
909
892
|
status: 'published',
|
|
910
|
-
|
|
893
|
+
locale: 'en',
|
|
911
894
|
limit: 10,
|
|
912
895
|
offset: 20,
|
|
913
896
|
orderBy: { column: 'createdAt', direction: 'desc' }
|
|
914
897
|
});
|
|
898
|
+
|
|
899
|
+
// Count without populate (cheap)
|
|
900
|
+
const total = await countEntries({ collection: 'posts', status: 'published' });
|
|
901
|
+
|
|
902
|
+
// Skip relation populating for one field — return raw IDs
|
|
903
|
+
const slimPosts = await resolveEntries({
|
|
904
|
+
collection: 'posts',
|
|
905
|
+
populate: { fields: { author: false } }
|
|
906
|
+
});
|
|
915
907
|
```
|
|
916
908
|
|
|
917
909
|
## Query Options
|
|
918
910
|
|
|
919
911
|
| Option | Type | Description |
|
|
920
912
|
|--------|------|-------------|
|
|
921
|
-
| `
|
|
922
|
-
| `
|
|
923
|
-
| `
|
|
924
|
-
| `
|
|
925
|
-
| `
|
|
926
|
-
| `
|
|
927
|
-
| `
|
|
928
|
-
| `
|
|
929
|
-
| `
|
|
930
|
-
| `limit` | `number` | Max entries to return |
|
|
931
|
-
| `offset` | `number` | Skip N entries (pagination) |
|
|
913
|
+
| `collection` | `string` | Collection or single slug. Required when `id` not given. |
|
|
914
|
+
| `id` | `string` | Specific entry ID (in `resolveEntry`). |
|
|
915
|
+
| `ids` | `string[]` | Filter by specific entry IDs (in `resolveEntries`). |
|
|
916
|
+
| `status` | `'draft' \| 'published' \| 'scheduled'` | Version status. Default `'published'`. `archived` is **not** a status — use `archive*` operations. |
|
|
917
|
+
| `locale` | `string` | Locale for localized fields. Defaults to `cms.languages[0]`. |
|
|
918
|
+
| `orderBy` | `{ column, direction }` | Sort by `'createdAt'`, `'updatedAt'`, or `'sortOrder'`. |
|
|
919
|
+
| `limit` | `number` | Max entries to return. |
|
|
920
|
+
| `offset` | `number` | Skip N entries (pagination). |
|
|
921
|
+
| `populate` | `{ maxDepth?, fields? }` | Cap recursion depth or opt out of populating specific fields. Default `maxDepth: 5`. |
|
|
932
922
|
|
|
933
923
|
## Using Entries on Frontend
|
|
934
924
|
|
|
935
925
|
In a SvelteKit `+page.server.ts` load function:
|
|
936
926
|
|
|
937
927
|
```typescript
|
|
938
|
-
import {
|
|
928
|
+
import { resolveEntries } from 'includio-cms/sveltekit/server';
|
|
939
929
|
|
|
940
930
|
export async function load() {
|
|
941
|
-
const posts = await
|
|
942
|
-
|
|
931
|
+
const posts = await resolveEntries({
|
|
932
|
+
collection: 'posts',
|
|
943
933
|
status: 'published',
|
|
944
|
-
|
|
934
|
+
locale: 'en',
|
|
945
935
|
limit: 10,
|
|
946
936
|
orderBy: { column: 'createdAt', direction: 'desc' }
|
|
947
937
|
});
|
|
@@ -969,6 +959,104 @@ Each entry in the result contains your field data plus metadata:
|
|
|
969
959
|
|
|
970
960
|
> **pathTemplate:** The `_url` field is auto-generated from the collection's `pathTemplate` (e.g. `'blog/{slug}'`) and the entry's slug field. Configure it in your collection/single definition.
|
|
971
961
|
|
|
962
|
+
## Error handling
|
|
963
|
+
|
|
964
|
+
Resolvers and write operations throw `CmsError` (extends `Error`) with a stable `code` and structured `context`. Match on `code` — never on the message text.
|
|
965
|
+
|
|
966
|
+
```typescript
|
|
967
|
+
import { resolveEntry, CmsError } from 'includio-cms';
|
|
968
|
+
|
|
969
|
+
try {
|
|
970
|
+
const post = await resolveEntry({ collection: 'posts', id: postId, locale: 'en' });
|
|
971
|
+
if (!post) {
|
|
972
|
+
// resolveEntry returns null when nothing matches — not an error
|
|
973
|
+
return error(404, 'Not found');
|
|
974
|
+
}
|
|
975
|
+
return { post };
|
|
976
|
+
} catch (e) {
|
|
977
|
+
if (e instanceof CmsError) {
|
|
978
|
+
if (e.code === 'MISSING_REQUIRED_PARAM') return error(400, e.message);
|
|
979
|
+
if (e.code === 'ENTRY_VERSION_NOT_FOUND') return error(404, 'No version for this locale');
|
|
980
|
+
}
|
|
981
|
+
throw e;
|
|
982
|
+
}
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
Stable codes (semver-protected, see [Stability Promise](/docs/stability-promise)):
|
|
986
|
+
|
|
987
|
+
| Code | Where | Meaning |
|
|
988
|
+
|---|---|---|
|
|
989
|
+
| `ENTRY_NOT_FOUND` | Internal `_getRawEntryOrThrow` (admin REST) | Entry record missing. |
|
|
990
|
+
| `ENTRY_VERSION_NOT_FOUND` | Internal `_getDbEntryVersionOrThrow` | No version row for the requested locale/status. |
|
|
991
|
+
| `INVALID_DATA` | `createEntryVersion`, `updateEntryVersion` | Zod validation against the field schema failed. `error.context.collection` and `error.context.entryId` set. |
|
|
992
|
+
| `MISSING_REQUIRED_PARAM` | `resolveEntry` / `resolveEntries` / `countEntries` | Neither `id` nor `collection` provided, or both omitted in a context that requires them. |
|
|
993
|
+
| `CONFIG_VALIDATION_FAILED` | `defineConfig` (boot) | Config-shape problem — see [Configuration](/docs/getting-started/configuration). |
|
|
994
|
+
|
|
995
|
+
Errors stringify as `[CODE] message (k=v, k=v)` — useful in logs. Never assert on `error.message` text:
|
|
996
|
+
|
|
997
|
+
```typescript
|
|
998
|
+
// BAD — fragile, message wording can change in patch releases
|
|
999
|
+
if (e.message === 'Entry not found') { /* ... */ }
|
|
1000
|
+
|
|
1001
|
+
// GOOD
|
|
1002
|
+
if (e instanceof CmsError && e.code === 'ENTRY_NOT_FOUND') { /* ... */ }
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
## Transaction patterns
|
|
1006
|
+
|
|
1007
|
+
The default Postgres adapter (`includio-cms/db-postgres`) uses short transactions inside individual operations (e.g. create + initial version) but **does not** wrap user-orchestrated multi-call sequences. Two patterns to know:
|
|
1008
|
+
|
|
1009
|
+
### Race conditions are versioned, not blocked
|
|
1010
|
+
|
|
1011
|
+
If two editors save the same entry at the same time, both writes succeed and produce two new versions. The newer version becomes the current draft. There's no optimistic-lock check — design for "last write wins" semantics.
|
|
1012
|
+
|
|
1013
|
+
For sensitive flows (e.g. publishing a price change), gate at the application layer:
|
|
1014
|
+
|
|
1015
|
+
```typescript
|
|
1016
|
+
const entry = await resolveEntry({ collection: 'products', id, locale: 'en' });
|
|
1017
|
+
if (entry?._version !== expectedVersion) {
|
|
1018
|
+
throw new Error('Stale view — reload and retry');
|
|
1019
|
+
}
|
|
1020
|
+
await updateEntryVersionCommand({ entryId: id, data: newData, type: 'published-now' });
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
### Custom transactions across multiple operations
|
|
1024
|
+
|
|
1025
|
+
If you need create-and-publish or create-N-related-entries to be atomic, run them inside a Drizzle transaction. The Postgres adapter exposes its `db` instance:
|
|
1026
|
+
|
|
1027
|
+
```typescript
|
|
1028
|
+
import { getCMS } from 'includio-cms/core';
|
|
1029
|
+
|
|
1030
|
+
const cms = getCMS();
|
|
1031
|
+
const db = cms.db.driver; // raw drizzle handle when using includio-cms/db-postgres
|
|
1032
|
+
|
|
1033
|
+
await db.transaction(async (tx) => {
|
|
1034
|
+
const author = await tx.insert(entries).values({ slug: 'authors' }).returning();
|
|
1035
|
+
await tx.insert(entries).values({
|
|
1036
|
+
slug: 'posts',
|
|
1037
|
+
// ... reference author.id
|
|
1038
|
+
});
|
|
1039
|
+
});
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
Inside the transaction, do **not** call CMS resolvers — they use the un-transacted connection. Either run the whole flow at the SQL layer, or accept that resolvers will read stale state until commit.
|
|
1043
|
+
|
|
1044
|
+
### Plugin hooks and partial failure
|
|
1045
|
+
|
|
1046
|
+
Plugin lifecycle hooks (`beforeCreate`, `afterUpdate`, etc.) run **outside** the database write — a hook throwing won't roll back the write that already committed. If a hook does network work that must succeed (webhook, downstream sync), enqueue it via your own retry mechanism instead of relying on rollback.
|
|
1047
|
+
|
|
1048
|
+
```typescript
|
|
1049
|
+
{
|
|
1050
|
+
hooks: {
|
|
1051
|
+
afterCreate: async (entry) => {
|
|
1052
|
+
// Don't throw on network errors — log + enqueue retry
|
|
1053
|
+
try { await fetch(WEBHOOK_URL, { method: 'POST', body: JSON.stringify(entry) }); }
|
|
1054
|
+
catch (e) { await queueRetry({ kind: 'webhook', entryId: entry._id }); }
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
```
|
|
1059
|
+
|
|
972
1060
|
|
|
973
1061
|
---
|
|
974
1062
|
|
|
@@ -1231,6 +1319,65 @@ Returns a `number`.
|
|
|
1231
1319
|
|
|
1232
1320
|
> **Decimals:** Set `step: 0.01` for currency values, or `step: 1` for integers only.
|
|
1233
1321
|
|
|
1322
|
+
## Edge cases
|
|
1323
|
+
|
|
1324
|
+
The number field is backed by a Zod `z.number()` schema. The defaults reject the silent failure modes you'd expect from a JavaScript number.
|
|
1325
|
+
|
|
1326
|
+
| Input | Result |
|
|
1327
|
+
|---|---|
|
|
1328
|
+
| `NaN` | Rejected by Zod (`expected number, received nan`). |
|
|
1329
|
+
| `Infinity`, `-Infinity` | Rejected (Zod requires finite). |
|
|
1330
|
+
| `"42"` (string) | Rejected unless coerced upstream — the field hands a number to Zod. |
|
|
1331
|
+
| `null` | Allowed only when the field is **not** `required`. |
|
|
1332
|
+
| `42.0000001` past `step: 0.01` | Accepted as-is. The admin UI rounds for display; storage keeps the raw value. |
|
|
1333
|
+
|
|
1334
|
+
### Floating-point precision
|
|
1335
|
+
|
|
1336
|
+
JavaScript numbers are IEEE-754 doubles, so `0.1 + 0.2 === 0.30000000000000004`. If the value is money or an exact unit count, store it with a `step` that matches your precision (e.g. `step: 0.01` for cents) and convert at the boundary:
|
|
1337
|
+
|
|
1338
|
+
```typescript
|
|
1339
|
+
// Field
|
|
1340
|
+
{ type: 'number', slug: 'priceUsd', label: 'Price (USD)', step: 0.01 }
|
|
1341
|
+
|
|
1342
|
+
// Frontend rendering (shop)
|
|
1343
|
+
const display = (entry.priceUsd ?? 0).toFixed(2); // "29.99"
|
|
1344
|
+
|
|
1345
|
+
// Database math — convert to integer cents to avoid drift
|
|
1346
|
+
const cents = Math.round(entry.priceUsd * 100);
|
|
1347
|
+
```
|
|
1348
|
+
|
|
1349
|
+
For high-precision money in the shop module, `numeric(20,6)` is the storage format — see [Shop Overview](/docs/shop).
|
|
1350
|
+
|
|
1351
|
+
### `min`/`max` are inclusive
|
|
1352
|
+
|
|
1353
|
+
```typescript
|
|
1354
|
+
{ type: 'number', slug: 'rating', min: 0, max: 5 }
|
|
1355
|
+
```
|
|
1356
|
+
|
|
1357
|
+
`0` and `5` are valid. To exclude an endpoint, narrow `step` (`min: 0.01` for "must be > 0") or check in your handler.
|
|
1358
|
+
|
|
1359
|
+
### Integer-only values
|
|
1360
|
+
|
|
1361
|
+
There's no native "integer" flag — emulate it with `step: 1`. The HTML input enforces step on UI submit; Zod runs the same comparison on the backend:
|
|
1362
|
+
|
|
1363
|
+
```typescript
|
|
1364
|
+
{ type: 'number', slug: 'pages', min: 1, step: 1 }
|
|
1365
|
+
// 5 → ok
|
|
1366
|
+
// 5.5 → rejected (`Number must be a multiple of 1`)
|
|
1367
|
+
```
|
|
1368
|
+
|
|
1369
|
+
### Parsing user input from forms
|
|
1370
|
+
|
|
1371
|
+
The form field receives a `number` already, but if you pre-process URLs or query params yourself, guard `parseFloat`:
|
|
1372
|
+
|
|
1373
|
+
```typescript
|
|
1374
|
+
const raw = url.searchParams.get('count');
|
|
1375
|
+
const count = raw ? Number(raw) : null;
|
|
1376
|
+
if (count !== null && !Number.isFinite(count)) {
|
|
1377
|
+
// null, NaN, Infinity — handle as invalid
|
|
1378
|
+
}
|
|
1379
|
+
```
|
|
1380
|
+
|
|
1234
1381
|
|
|
1235
1382
|
---
|
|
1236
1383
|
|
|
@@ -1277,6 +1424,56 @@ Returns a `boolean`.
|
|
|
1277
1424
|
|
|
1278
1425
|
> **Common Uses:** Use for feature flags, visibility toggles, or any binary state like "Show in navigation" or "Allow comments".
|
|
1279
1426
|
|
|
1427
|
+
## Edge cases
|
|
1428
|
+
|
|
1429
|
+
### `null` vs `false`
|
|
1430
|
+
|
|
1431
|
+
These mean different things:
|
|
1432
|
+
|
|
1433
|
+
- `false` — explicit "off". The user toggled it.
|
|
1434
|
+
- `null` — "not set". The field is optional and was never touched.
|
|
1435
|
+
|
|
1436
|
+
If you treat them the same, you'll surprise users who expected the default to apply. Always pick one and stick with it:
|
|
1437
|
+
|
|
1438
|
+
```typescript
|
|
1439
|
+
// Pattern A — required, always boolean
|
|
1440
|
+
{ type: 'boolean', slug: 'featured', required: true, defaultValue: false }
|
|
1441
|
+
// entry.featured is `boolean`
|
|
1442
|
+
|
|
1443
|
+
// Pattern B — optional, may be null
|
|
1444
|
+
{ type: 'boolean', slug: 'showInNav', required: false }
|
|
1445
|
+
// entry.showInNav is `boolean | null`
|
|
1446
|
+
|
|
1447
|
+
// Frontend: always normalize
|
|
1448
|
+
const showInNav = entry.showInNav ?? true; // default to "on" if unset
|
|
1449
|
+
```
|
|
1450
|
+
|
|
1451
|
+
### Don't coerce strings
|
|
1452
|
+
|
|
1453
|
+
A common bug: getting `"false"` from a query string and using it directly:
|
|
1454
|
+
|
|
1455
|
+
```typescript
|
|
1456
|
+
const flag = url.searchParams.get('preview'); // "false" or null
|
|
1457
|
+
if (flag) { /* runs even when flag is "false"! */ }
|
|
1458
|
+
|
|
1459
|
+
// Correct
|
|
1460
|
+
const flag = url.searchParams.get('preview') === 'true';
|
|
1461
|
+
```
|
|
1462
|
+
|
|
1463
|
+
This isn't specific to the field, but the field stores a real `boolean` — converting back is your responsibility.
|
|
1464
|
+
|
|
1465
|
+
### Default value in schema vs runtime
|
|
1466
|
+
|
|
1467
|
+
`defaultValue` in the field config controls the **admin form's initial state** for new entries. It does **not** retroactively populate existing entries. If you add `defaultValue: true` to a field that previously had no default, entries created before the change still have whatever they had (often `null`).
|
|
1468
|
+
|
|
1469
|
+
For a one-time backfill, run a migration:
|
|
1470
|
+
|
|
1471
|
+
```sql
|
|
1472
|
+
UPDATE entry_versions
|
|
1473
|
+
SET data = jsonb_set(data, '{showInNav}', 'true')
|
|
1474
|
+
WHERE data->>'showInNav' IS NULL;
|
|
1475
|
+
```
|
|
1476
|
+
|
|
1280
1477
|
|
|
1281
1478
|
---
|
|
1282
1479
|
|
|
@@ -1325,6 +1522,57 @@ Returns an ISO date `string`.
|
|
|
1325
1522
|
|
|
1326
1523
|
> **Date vs DateTime:** Use Date for day-level precision (publish dates, birthdays). Use [DateTime](/docs/fields/datetime) when you need time-of-day.
|
|
1327
1524
|
|
|
1525
|
+
## Edge cases
|
|
1526
|
+
|
|
1527
|
+
### Storage format
|
|
1528
|
+
|
|
1529
|
+
Stored as an ISO-8601 calendar date (`YYYY-MM-DD`), no time, no timezone. There's no `Date` instance on disk — just the string. Parse to a `Date` only when you need to compute (`new Date(entry.publishedAt + 'T00:00:00Z')`).
|
|
1530
|
+
|
|
1531
|
+
### Timezones — there are none
|
|
1532
|
+
|
|
1533
|
+
A `date` field has no timezone. `"2024-06-15"` is the same calendar day everywhere. **Don't convert it through `new Date()` for display** — that introduces a phantom timezone shift:
|
|
1534
|
+
|
|
1535
|
+
```typescript
|
|
1536
|
+
// BAD — shifts to UTC, may render as "2024-06-14" depending on locale
|
|
1537
|
+
new Date(entry.publishedAt).toLocaleDateString();
|
|
1538
|
+
|
|
1539
|
+
// GOOD — split, format as-is
|
|
1540
|
+
const [year, month, day] = entry.publishedAt.split('-');
|
|
1541
|
+
const display = `${day}.${month}.${year}`;
|
|
1542
|
+
```
|
|
1543
|
+
|
|
1544
|
+
If you absolutely need a timezone, switch to [DateTime](/docs/fields/datetime).
|
|
1545
|
+
|
|
1546
|
+
### `null` handling
|
|
1547
|
+
|
|
1548
|
+
When `required: false`, the value can be `null`. Always default before formatting:
|
|
1549
|
+
|
|
1550
|
+
```typescript
|
|
1551
|
+
const date = entry.publishedAt ?? '1970-01-01';
|
|
1552
|
+
```
|
|
1553
|
+
|
|
1554
|
+
### Invalid strings
|
|
1555
|
+
|
|
1556
|
+
The admin UI uses a calendar picker, so it can't emit invalid dates. The REST API and programmatic flows can — pre-validate with `z.string().date()` if you accept user input from outside the admin:
|
|
1557
|
+
|
|
1558
|
+
```typescript
|
|
1559
|
+
import { z } from 'zod';
|
|
1560
|
+
const Schema = z.object({ publishedAt: z.string().date() }); // YYYY-MM-DD
|
|
1561
|
+
```
|
|
1562
|
+
|
|
1563
|
+
### `minDate` / `maxDate` use the same string format
|
|
1564
|
+
|
|
1565
|
+
Compare as strings — ISO `YYYY-MM-DD` sorts lexicographically:
|
|
1566
|
+
|
|
1567
|
+
```typescript
|
|
1568
|
+
{ type: 'date', slug: 'eventDate', minDate: '2024-01-01', maxDate: '2025-12-31' }
|
|
1569
|
+
// '2024-06-15' >= '2024-01-01' → ok
|
|
1570
|
+
```
|
|
1571
|
+
|
|
1572
|
+
### Edge dates
|
|
1573
|
+
|
|
1574
|
+
Years before 1000 or after 9999 break ISO formatting in some pickers. If you need historical or far-future dates, store as a regular text field with your own parser, not `date`.
|
|
1575
|
+
|
|
1328
1576
|
|
|
1329
1577
|
---
|
|
1330
1578
|
|
|
@@ -1373,6 +1621,57 @@ Returns an ISO datetime `string`.
|
|
|
1373
1621
|
|
|
1374
1622
|
> **Scheduling:** Use DateTime for scheduled publishing, event start/end times, or any timestamp that needs time-of-day precision.
|
|
1375
1623
|
|
|
1624
|
+
## Edge cases
|
|
1625
|
+
|
|
1626
|
+
### Always store UTC, render local
|
|
1627
|
+
|
|
1628
|
+
The picker writes a UTC ISO timestamp (`2024-06-15T14:30:00.000Z`). Render with `toLocaleString` so users see their own timezone:
|
|
1629
|
+
|
|
1630
|
+
```svelte
|
|
1631
|
+
<time datetime={entry.scheduledAt}>{local}</time>
|
|
1632
|
+
```
|
|
1633
|
+
|
|
1634
|
+
### DST transitions
|
|
1635
|
+
|
|
1636
|
+
Daylight saving creates ambiguous local times — e.g. in `America/New_York`, the clock skips from 02:00 to 03:00 on the spring switch, so `02:30` doesn't exist that day. Storing UTC dodges this entirely; the issue only appears if you accept _local_ datetimes from a form. If you do, convert at the boundary and warn for known-ambiguous times.
|
|
1637
|
+
|
|
1638
|
+
### `null` and invalid strings
|
|
1639
|
+
|
|
1640
|
+
```typescript
|
|
1641
|
+
// REST or programmatic flow — validate first
|
|
1642
|
+
import { z } from 'zod';
|
|
1643
|
+
const Schema = z.object({ scheduledAt: z.string().datetime() }); // ISO 8601 full
|
|
1644
|
+
```
|
|
1645
|
+
|
|
1646
|
+
`null` is allowed when `required: false`. Default before parsing:
|
|
1647
|
+
|
|
1648
|
+
```typescript
|
|
1649
|
+
const ts = entry.scheduledAt ? new Date(entry.scheduledAt) : null;
|
|
1650
|
+
```
|
|
1651
|
+
|
|
1652
|
+
### Comparison
|
|
1653
|
+
|
|
1654
|
+
Always compare as `Date` objects or ISO strings — both sort correctly:
|
|
1655
|
+
|
|
1656
|
+
```typescript
|
|
1657
|
+
const past = entry.scheduledAt < new Date().toISOString();
|
|
1658
|
+
// or
|
|
1659
|
+
const past = new Date(entry.scheduledAt) < new Date();
|
|
1660
|
+
```
|
|
1661
|
+
|
|
1662
|
+
### Range with `minDate` / `maxDate`
|
|
1663
|
+
|
|
1664
|
+
The bounds are full ISO timestamps. Lexicographic comparison still works for ISO strings:
|
|
1665
|
+
|
|
1666
|
+
```typescript
|
|
1667
|
+
{
|
|
1668
|
+
type: 'datetime',
|
|
1669
|
+
slug: 'startsAt',
|
|
1670
|
+
minDate: '2024-01-01T00:00:00.000Z',
|
|
1671
|
+
maxDate: '2025-12-31T23:59:59.999Z'
|
|
1672
|
+
}
|
|
1673
|
+
```
|
|
1674
|
+
|
|
1376
1675
|
|
|
1377
1676
|
---
|
|
1378
1677
|
|
|
@@ -2974,6 +3273,111 @@ const myCustomDb: DatabaseAdapter = {
|
|
|
2974
3273
|
|
|
2975
3274
|
> **Custom Adapters:** You can build adapters for any provider (MySQL, S3, Resend, Claude, etc). Just implement the required interface methods and pass the adapter to `defineConfig`.
|
|
2976
3275
|
|
|
3276
|
+
## Adapter contracts
|
|
3277
|
+
|
|
3278
|
+
Adapter interfaces are part of the public API surface (`@public`). They will not break inside a major version — see [Stability Promise](/docs/stability-promise).
|
|
3279
|
+
|
|
3280
|
+
| Interface | Methods | Optional? | Default implementation |
|
|
3281
|
+
|---|---:|---|---|
|
|
3282
|
+
| `DatabaseAdapter` | 38 | required | `includio-cms/db-postgres` |
|
|
3283
|
+
| `FilesAdapter` | 5 + 3 optional | required | `includio-cms/files-local` |
|
|
3284
|
+
| `EmailAdapter` | 1 + 2 config props | required | `includio-cms/email-nodemailer` |
|
|
3285
|
+
| `AIAdapter` | 1 | optional (only if `ai:` set) | `includio-cms/ai-openai`, `includio-cms/ai-claude` |
|
|
3286
|
+
|
|
3287
|
+
Optional methods are marked with `?` in the type definition (`getConsentLogs?`, `uploadPrivateFile?`). Returning `undefined` is fine — the CMS skips features that depend on absent capabilities.
|
|
3288
|
+
|
|
3289
|
+
Full method lists live in the source:
|
|
3290
|
+
|
|
3291
|
+
- `src/lib/types/adapters/db.ts` — `DatabaseAdapter`
|
|
3292
|
+
- `src/lib/types/adapters/files.ts` — `FilesAdapter`
|
|
3293
|
+
- `src/lib/types/adapters/email.ts` — `EmailAdapter`
|
|
3294
|
+
- `src/lib/types/adapters/ai.ts` — `AIAdapter`
|
|
3295
|
+
|
|
3296
|
+
Browse the per-adapter docs ([Database](/docs/adapters/database), [Files](/docs/adapters/files), [Email](/docs/adapters/email), [AI](/docs/adapters/ai)) for method-level expectations and the README's "Writing your own adapter" section for a worked example.
|
|
3297
|
+
|
|
3298
|
+
## Peer dependencies — required vs optional
|
|
3299
|
+
|
|
3300
|
+
The CMS package declares its peers in `package.json`. Some are required for the CMS to boot at all; others are tied to specific adapters and only need to be installed if you use them.
|
|
3301
|
+
|
|
3302
|
+
**Required** (must be installed in your app):
|
|
3303
|
+
|
|
3304
|
+
```text
|
|
3305
|
+
@sveltejs/kit, @sveltejs/vite-plugin-svelte, better-auth, bits-ui,
|
|
3306
|
+
drizzle-orm, formsnap, postgres, runed, sharp, svelte,
|
|
3307
|
+
sveltekit-superforms, tailwind-merge, tailwindcss, vite, zod
|
|
3308
|
+
```
|
|
3309
|
+
|
|
3310
|
+
**Optional** (`peerDependenciesMeta.optional` — install only if you use the matching adapter or feature):
|
|
3311
|
+
|
|
3312
|
+
| Package | When to install |
|
|
3313
|
+
|---|---|
|
|
3314
|
+
| `nodemailer` | `includio-cms/email-nodemailer` |
|
|
3315
|
+
| `openai` | `includio-cms/ai-openai` |
|
|
3316
|
+
| `@anthropic-ai/sdk` | `includio-cms/ai-claude` |
|
|
3317
|
+
| `svelte-tiptap` | rich-text content field |
|
|
3318
|
+
| `paneforge` | admin layout split-panes |
|
|
3319
|
+
| `svelte-sonner` | admin toasts |
|
|
3320
|
+
| `embla-carousel-svelte` | optional carousel UI |
|
|
3321
|
+
|
|
3322
|
+
Install only what you need:
|
|
3323
|
+
|
|
3324
|
+
```bash
|
|
3325
|
+
# Minimum production app (db + local files only)
|
|
3326
|
+
pnpm add includio-cms
|
|
3327
|
+
|
|
3328
|
+
# With email and OpenAI alt-text
|
|
3329
|
+
pnpm add includio-cms nodemailer openai
|
|
3330
|
+
```
|
|
3331
|
+
|
|
3332
|
+
The CLI `includio install-peers` command walks you through installing required peers based on your `cms.config.ts`.
|
|
3333
|
+
|
|
3334
|
+
## Lazy import pattern
|
|
3335
|
+
|
|
3336
|
+
Optional SDK peers (`nodemailer`, `openai`, `@anthropic-ai/sdk`) are imported **lazily** inside their adapter factories — never at module top-level. This keeps the bundle clean for users who don't use the adapter, even though the import statement exists in `dist/`.
|
|
3337
|
+
|
|
3338
|
+
```typescript
|
|
3339
|
+
// Pattern used by includio-cms/email-nodemailer
|
|
3340
|
+
export const nodemailerAdapter = (config: NodemailerConfig): EmailAdapter => {
|
|
3341
|
+
let transporterPromise: Promise<Transporter> | null = null;
|
|
3342
|
+
|
|
3343
|
+
const getTransporter = async () => {
|
|
3344
|
+
if (!transporterPromise) {
|
|
3345
|
+
// Imported only on first sendMail call
|
|
3346
|
+
const { createTransport } = await import('nodemailer');
|
|
3347
|
+
transporterPromise = Promise.resolve(createTransport(config.transportOptions));
|
|
3348
|
+
}
|
|
3349
|
+
return transporterPromise;
|
|
3350
|
+
};
|
|
3351
|
+
|
|
3352
|
+
return {
|
|
3353
|
+
defaultFromAddress: config.defaultFromAddress,
|
|
3354
|
+
defaultFromName: config.defaultFromName,
|
|
3355
|
+
sendMail: async ({ to, subject, html }) => {
|
|
3356
|
+
const t = await getTransporter();
|
|
3357
|
+
await t.sendMail({ from: config.defaultFromAddress, to, subject, html });
|
|
3358
|
+
}
|
|
3359
|
+
};
|
|
3360
|
+
};
|
|
3361
|
+
```
|
|
3362
|
+
|
|
3363
|
+
If your custom adapter wraps a heavyweight SDK, follow the same shape: defer the `import('your-sdk')` until the first call that needs it.
|
|
3364
|
+
|
|
3365
|
+
## Error contracts
|
|
3366
|
+
|
|
3367
|
+
Adapters can throw any `Error` (or subclass). The CMS does **not** wrap unknown errors automatically — they propagate up to your handler. For consistency, surface the same kinds of failures the built-in adapters do:
|
|
3368
|
+
|
|
3369
|
+
- **Configuration errors** at construction time (e.g. missing API key) — throw a plain `Error` with a clear message. The CMS will fail fast at `getCMS()`.
|
|
3370
|
+
- **Recoverable runtime errors** (rate limit, transient network) — throw a typed error your callers can match on, or return `null` from a read method when "not found" is the natural answer.
|
|
3371
|
+
- **Validation errors** are the CMS's job — adapters should accept the data shape they're given and not re-validate.
|
|
3372
|
+
|
|
3373
|
+
## Best practices
|
|
3374
|
+
|
|
3375
|
+
- **Timeouts.** Wrap third-party calls in a timeout (Sharp's 30 s wrapper in `src/lib/server/utils/withTimeout.ts` is a useful template). A hung adapter pins a SvelteKit worker.
|
|
3376
|
+
- **Retry strategy is the caller's job.** Adapters should fail fast and surface the error. Retries belong to whatever orchestrates the call (background job, queue, user-initiated action).
|
|
3377
|
+
- **Be honest about partial state.** If `uploadFile` writes to disk but the DB insert fails, the file is leaked. The built-in `files-local` survives this via the maintenance GC pass — design your adapter so a similar reconciliation is possible.
|
|
3378
|
+
- **Don't reach into other adapters.** A `FilesAdapter` should not query the DB. The CMS coordinates cross-adapter flows; adapters stay narrow.
|
|
3379
|
+
- **Bench locally.** Run a `pnpm test:integration` with your adapter swapped in before production. The integration suite covers ~30 REST scenarios and 6 shop checkout flows that exercise the adapter contract end-to-end.
|
|
3380
|
+
|
|
2977
3381
|
|
|
2978
3382
|
---
|
|
2979
3383
|
|
|
@@ -3527,38 +3931,192 @@ Requires the **email adapter** to be configured. Without it, password reset is u
|
|
|
3527
3931
|
|
|
3528
3932
|
---
|
|
3529
3933
|
|
|
3530
|
-
#
|
|
3934
|
+
# Security Model
|
|
3531
3935
|
|
|
3532
|
-
|
|
3936
|
+
Includio CMS ships with security defaults that work for typical single-tenant deployments. This page describes what's enforced, what's configurable, and where the trade-offs are.
|
|
3533
3937
|
|
|
3534
|
-
|
|
3938
|
+
> **Accepted risks:** Some defaults trade strictness for compatibility with common dependencies. Each trade-off is documented in `KNOWN-RISKS.md` at the repo root. See "Known accepted risks" at the bottom of this page.
|
|
3535
3939
|
|
|
3536
|
-
|
|
3537
|
-
import type { PluginConfig } from 'includio-cms/types';
|
|
3940
|
+
## Threat model in scope
|
|
3538
3941
|
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
console.log('Updated entry:', entry.id);
|
|
3547
|
-
},
|
|
3548
|
-
afterDelete: async (id) => {
|
|
3549
|
-
console.log('Deleted entry:', id);
|
|
3550
|
-
}
|
|
3551
|
-
}
|
|
3552
|
-
};
|
|
3553
|
-
```
|
|
3942
|
+
| In scope | Out of scope |
|
|
3943
|
+
|---|---|
|
|
3944
|
+
| CSRF on admin endpoints | Network-level DDoS (use a CDN/WAF) |
|
|
3945
|
+
| Brute force on admin login + API keys | Physical/host security of your deploy |
|
|
3946
|
+
| XSS via stored content (TipTap, SEO, custom fields) | Compromised admin browser/extensions |
|
|
3947
|
+
| Image/video processing crashes (Sharp/ffmpeg) | Compromised database server |
|
|
3948
|
+
| Unauthorized API key reuse | Compromised filesystem on the host |
|
|
3554
3949
|
|
|
3555
|
-
##
|
|
3950
|
+
## CSRF Protection
|
|
3556
3951
|
|
|
3557
|
-
|
|
3952
|
+
Mutating requests to `/admin/api/*` (POST, PUT, PATCH, DELETE) require a same-origin `Origin` or `Referer` header. The check lives in `src/lib/server/security/csrf.ts` and runs from the SvelteKit `handle` chain.
|
|
3558
3953
|
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3954
|
+
A request with no `Origin`/`Referer` or with a foreign origin returns `403 Forbidden` and does not reach handlers.
|
|
3955
|
+
|
|
3956
|
+
```ts
|
|
3957
|
+
// Configure allowed origins (default: same-origin only)
|
|
3958
|
+
// .env
|
|
3959
|
+
INCLUDIO_CSRF_ALLOWED_ORIGINS=https://staging.example.com,https://admin.example.com
|
|
3960
|
+
```
|
|
3961
|
+
|
|
3962
|
+
Public endpoints (`/api/forms/*/submit`, shop public API) have their own protections (rate limit, signed cookies for cart) and are not behind CSRF.
|
|
3963
|
+
|
|
3964
|
+
## Content Security Policy
|
|
3965
|
+
|
|
3966
|
+
A CSP header is set on every admin response. The shape:
|
|
3967
|
+
|
|
3968
|
+
```
|
|
3969
|
+
default-src 'self';
|
|
3970
|
+
script-src 'self' 'unsafe-inline';
|
|
3971
|
+
style-src 'self' 'unsafe-inline';
|
|
3972
|
+
object-src 'none';
|
|
3973
|
+
frame-ancestors 'self';
|
|
3974
|
+
base-uri 'self';
|
|
3975
|
+
img-src 'self' data: blob:;
|
|
3976
|
+
connect-src 'self';
|
|
3977
|
+
```
|
|
3978
|
+
|
|
3979
|
+
`'unsafe-inline'` on `script-src` and `style-src` is **required** by two admin dependencies (TipTap rich-text editor, Paraglide i18n runtime). The other directives still cover the high-impact vectors:
|
|
3980
|
+
|
|
3981
|
+
- `object-src 'none'` blocks Flash/legacy plugins.
|
|
3982
|
+
- `frame-ancestors 'self'` blocks clickjacking.
|
|
3983
|
+
- `base-uri 'self'` blocks `<base>` injection.
|
|
3984
|
+
|
|
3985
|
+
This trade-off is logged as **KNOWN-RISKS §1**. Plan to revisit when TipTap supports nonce-based CSP.
|
|
3986
|
+
|
|
3987
|
+
## Rate Limiting
|
|
3988
|
+
|
|
3989
|
+
Two rate limit guards ship by default, both backed by the in-process `MemoryRateLimitStore`:
|
|
3990
|
+
|
|
3991
|
+
| Surface | Default limit | Env override |
|
|
3992
|
+
|---|---|---|
|
|
3993
|
+
| `/admin/api/*` | 200 requests / 60 s per IP | `INCLUDIO_RATE_LIMIT_MAX`, `INCLUDIO_RATE_LIMIT_WINDOW_MS` |
|
|
3994
|
+
| `POST /api/forms/[slug]/submit` | 5 requests / 1 h per IP | `INCLUDIO_FORM_RATE_LIMIT_MAX`, `INCLUDIO_FORM_RATE_LIMIT_WINDOW_MS` |
|
|
3995
|
+
|
|
3996
|
+
```ts
|
|
3997
|
+
// Example: lower the form limit for a high-volume newsletter form
|
|
3998
|
+
// .env
|
|
3999
|
+
INCLUDIO_FORM_RATE_LIMIT_MAX=20
|
|
4000
|
+
INCLUDIO_FORM_RATE_LIMIT_WINDOW_MS=3600000
|
|
4001
|
+
```
|
|
4002
|
+
|
|
4003
|
+
Hitting the limit returns `429 Too Many Requests` with a `Retry-After` header.
|
|
4004
|
+
|
|
4005
|
+
> **Multi-node deployments:** The default store is in-process. Behind a load balancer with N nodes, the effective limit is N×. This is **KNOWN-RISKS §3** — fix is a Redis-backed store, planned post-v1.0 as `includio-cms/rate-limit-redis`.
|
|
4006
|
+
|
|
4007
|
+
The `RateLimitStore` interface is pluggable — provide a custom store today if you need shared state:
|
|
4008
|
+
|
|
4009
|
+
```ts
|
|
4010
|
+
import { rateLimitGuard } from 'includio-cms/sveltekit/server';
|
|
4011
|
+
import { redisStore } from './my-redis-store.js';
|
|
4012
|
+
|
|
4013
|
+
// Wire it into your handle chain
|
|
4014
|
+
rateLimitGuard({ store: redisStore });
|
|
4015
|
+
```
|
|
4016
|
+
|
|
4017
|
+
## API Keys
|
|
4018
|
+
|
|
4019
|
+
API keys authenticate REST clients (`/admin/api/*` consumers, third-party integrations). Configure them in `cms.config.ts`:
|
|
4020
|
+
|
|
4021
|
+
```ts
|
|
4022
|
+
import { defineConfig, generateApiKey } from 'includio-cms/sveltekit';
|
|
4023
|
+
|
|
4024
|
+
export default defineConfig({
|
|
4025
|
+
// ...
|
|
4026
|
+
apiKeys: [
|
|
4027
|
+
{
|
|
4028
|
+
key: generateApiKey(), // 32 bytes, base64url
|
|
4029
|
+
name: 'mobile-app',
|
|
4030
|
+
role: 'editor',
|
|
4031
|
+
expiresAt: '2027-01-01T00:00:00Z', // opt-in expiry
|
|
4032
|
+
rotatedAt: '2026-04-30T10:00:00Z' // audit trail (not enforced)
|
|
4033
|
+
}
|
|
4034
|
+
]
|
|
4035
|
+
});
|
|
4036
|
+
```
|
|
4037
|
+
|
|
4038
|
+
- `generateApiKey()` returns a 256-bit cryptographic token (43 chars, URL-safe).
|
|
4039
|
+
- `expiresAt` is **opt-in**. Without it, the key never expires. Once set, an expired key returns generic `401 Unauthorized` (no leak about whether the key existed).
|
|
4040
|
+
- `rotatedAt` is purely audit metadata — not enforced. Update it when you manually rotate.
|
|
4041
|
+
- Comparison is timing-safe (`crypto.timingSafeEqual`).
|
|
4042
|
+
|
|
4043
|
+
> **Static key model:** Keys live in the config file and load into process memory at boot. Rotation requires updating `cms.config.ts` and redeploying. There's no `POST /admin/api/keys/rotate` endpoint in v1.0 — multi-tenant rotation is **KNOWN-RISKS §2**, deferred post-v1.0.
|
|
4044
|
+
|
|
4045
|
+
## Image processing safety
|
|
4046
|
+
|
|
4047
|
+
Every Sharp call (`metadata`, `resize`, `toBuffer`) is wrapped in a 30-second timeout. Without it, a malicious upload (zip-bomb image, ultra-high-resolution TIFF) could pin a worker indefinitely.
|
|
4048
|
+
|
|
4049
|
+
```ts
|
|
4050
|
+
// Configurable via env
|
|
4051
|
+
// .env
|
|
4052
|
+
INCLUDIO_SHARP_TIMEOUT_MS=60000 # default 30000
|
|
4053
|
+
```
|
|
4054
|
+
|
|
4055
|
+
A timeout aborts the upload and logs `TimeoutError` to stderr — the original file remains in storage but is not finalized in the DB. See `src/lib/server/utils/withTimeout.ts`.
|
|
4056
|
+
|
|
4057
|
+
Subprocess audits for `ffmpeg` and `sharp` (no shell, args passed as arrays, paths normalized) are documented in **KNOWN-RISKS §5**.
|
|
4058
|
+
|
|
4059
|
+
## Other defaults
|
|
4060
|
+
|
|
4061
|
+
| Default | Source |
|
|
4062
|
+
|---|---|
|
|
4063
|
+
| HTTP-only, signed session cookies | `better-auth` |
|
|
4064
|
+
| `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin` | `src/lib/sveltekit/server/handle.ts` |
|
|
4065
|
+
| Filename sanitization (Unicode NFC, no `..`) on uploads | `src/lib/files-local/sanitizeFilename.ts` |
|
|
4066
|
+
| MIME blocklist on uploads | `src/lib/core/server/media/mimeBlocklist.ts` |
|
|
4067
|
+
| Timing-safe API key comparison | `src/lib/admin/api/rest/middleware/apiKey.ts` |
|
|
4068
|
+
|
|
4069
|
+
## Known accepted risks
|
|
4070
|
+
|
|
4071
|
+
Five trade-offs are documented in **`KNOWN-RISKS.md`** at the repository root. Each entry follows the format _description → mitigation → when fix_:
|
|
4072
|
+
|
|
4073
|
+
1. **CSP `'unsafe-inline'`** — required by TipTap/Paraglide. Fix when nonce-based CSP is supported upstream.
|
|
4074
|
+
2. **API key rotation = opt-in** — `expiresAt` optional. Fix when DB-backed rotation lands (post-v1.0).
|
|
4075
|
+
3. **Rate limit store is in-process** — single-node only by default. Fix with `includio-cms/rate-limit-redis` (post-v1.0).
|
|
4076
|
+
4. **Sharp 30 s timeout** — ultra-large/expensive images error. Fix by raising `INCLUDIO_SHARP_TIMEOUT_MS` or pre-validating dimensions.
|
|
4077
|
+
5. **ffmpeg/sharp CLI args** — audit complete, all uses safe. Re-audit if user-controllable transcode params are ever added.
|
|
4078
|
+
|
|
4079
|
+
Read the full file before deploying to a multi-tenant or compliance-sensitive environment.
|
|
4080
|
+
|
|
4081
|
+
## Reporting a vulnerability
|
|
4082
|
+
|
|
4083
|
+
Email `patryk.pilarczyk.00@gmail.com` with subject `[security] AriaCMS …`. Do not file a public GitHub issue for unpatched security bugs.
|
|
4084
|
+
|
|
4085
|
+
|
|
4086
|
+
---
|
|
4087
|
+
|
|
4088
|
+
# Plugins
|
|
4089
|
+
|
|
4090
|
+
Plugins extend CMS behavior through lifecycle hooks that run before or after CRUD operations.
|
|
4091
|
+
|
|
4092
|
+
## Defining a Plugin
|
|
4093
|
+
|
|
4094
|
+
```typescript
|
|
4095
|
+
import type { PluginConfig } from 'includio-cms/types';
|
|
4096
|
+
|
|
4097
|
+
const auditLog: PluginConfig = {
|
|
4098
|
+
slug: 'audit-log',
|
|
4099
|
+
hooks: {
|
|
4100
|
+
afterCreate: async (entry) => {
|
|
4101
|
+
console.log('Created entry:', entry.id, entry.slug);
|
|
4102
|
+
},
|
|
4103
|
+
afterUpdate: async (entry) => {
|
|
4104
|
+
console.log('Updated entry:', entry.id);
|
|
4105
|
+
},
|
|
4106
|
+
afterDelete: async (id) => {
|
|
4107
|
+
console.log('Deleted entry:', id);
|
|
4108
|
+
}
|
|
4109
|
+
}
|
|
4110
|
+
};
|
|
4111
|
+
```
|
|
4112
|
+
|
|
4113
|
+
## Hook Lifecycle
|
|
4114
|
+
|
|
4115
|
+
Hooks execute in this order:
|
|
4116
|
+
|
|
4117
|
+
1. `beforeCreate` / `beforeUpdate` / `beforeDelete` — Before the database operation
|
|
4118
|
+
2. **Database operation executes**
|
|
4119
|
+
3. `afterCreate` / `afterUpdate` / `afterDelete` — After the operation succeeds
|
|
3562
4120
|
|
|
3563
4121
|
## Available Hooks
|
|
3564
4122
|
|
|
@@ -3775,6 +4333,40 @@ When building admin UI plugins:
|
|
|
3775
4333
|
3. Use `glass-inset` for input areas or recessed sections
|
|
3776
4334
|
4. Test in both light and dark modes
|
|
3777
4335
|
|
|
4336
|
+
## Accessibility
|
|
4337
|
+
|
|
4338
|
+
The admin UI targets WCAG 2.1 AA. Built-in defaults:
|
|
4339
|
+
|
|
4340
|
+
| Surface | What's enforced |
|
|
4341
|
+
|---|---|
|
|
4342
|
+
| Color contrast | Plus Jakarta Sans + the brand palette hits ≥ 4.5:1 for body text and ≥ 3:1 for large text and UI graphics. Don't override `--depth-*-bg` or text-color tokens without re-checking. |
|
|
4343
|
+
| Keyboard navigation | Every action button is reachable with `Tab`. `Esc` closes modals/popovers, `Enter`/`Space` activates focused controls. Tabbing through `bits-ui` menus restores focus to the trigger. |
|
|
4344
|
+
| Focus management | `:focus-visible` outlines on every interactive element. Modals trap focus until dismissed; restoring focus after close is handled by `bits-ui`. |
|
|
4345
|
+
| Skip-link | The admin layout includes a "Skip to main content" link as the first focusable element. |
|
|
4346
|
+
| Heading hierarchy | One `<h1>` per page (the breadcrumb-driven page title). Sections use `<h2>`/`<h3>` consistently. |
|
|
4347
|
+
| ARIA roles | Inline alerts use `role="alert"` + `aria-live="polite"`. Toasters from `svelte-sonner` announce via the same mechanism. Form fields are wired via `aria-describedby` to their hint and error nodes. |
|
|
4348
|
+
| Reduced motion | Animations respect `prefers-reduced-motion`. Custom transitions in plugins should follow the same rule. |
|
|
4349
|
+
|
|
4350
|
+
### Building accessible custom panels
|
|
4351
|
+
|
|
4352
|
+
When you scaffold a custom admin page or plugin panel, preserve the same defaults. The minimal pattern:
|
|
4353
|
+
|
|
4354
|
+
```svelte
|
|
4355
|
+
<section aria-labelledby="panel-title" class="glass-depth-1 rounded-lg p-4">
|
|
4356
|
+
<h2 id="panel-title">{title}</h2>
|
|
4357
|
+
{@render children()}
|
|
4358
|
+
</section>
|
|
4359
|
+
```
|
|
4360
|
+
|
|
4361
|
+
Things that come up most often when adding custom UI:
|
|
4362
|
+
|
|
4363
|
+
- **Buttons, not divs.** Use `<button type="button">` or `<a href>` — never an `onclick` on a `<div>`. Screen readers and keyboards rely on the role.
|
|
4364
|
+
- **Label every input.** A `<label for>` paired with the input, or `aria-label` if visually labelless. The form fields shipped with the admin already do this; don't bypass them with raw inputs.
|
|
4365
|
+
- **Announce async results.** When a save succeeds, push a toast or update an `aria-live` region. A silent UI is invisible to screen readers.
|
|
4366
|
+
- **Don't kill focus rings.** `outline: none` on focus is a WCAG violation unless you replace it with an equivalent indicator. The admin uses `:focus-visible` with a 2px outline at sufficient contrast.
|
|
4367
|
+
|
|
4368
|
+
> **Audit before shipping:** WCAG/ATAG compliance is a v1.x roadmap item — current defaults are correct on the surfaces above, but a full audit (axe, manual screen-reader passes) is post-v1.0. If you're targeting public-sector or accessibility-regulated deployments, schedule your own audit.
|
|
4369
|
+
|
|
3778
4370
|
|
|
3779
4371
|
---
|
|
3780
4372
|
|
|
@@ -4055,62 +4647,60 @@ Type-safe, RPC-style communication for SvelteKit apps.
|
|
|
4055
4647
|
For `+page.server.ts` load functions, you can also import from `includio-cms/sveltekit/server`:
|
|
4056
4648
|
|
|
4057
4649
|
```typescript
|
|
4058
|
-
import {
|
|
4650
|
+
import { resolveEntry, resolveEntries, countEntries } from 'includio-cms/sveltekit/server';
|
|
4059
4651
|
```
|
|
4060
4652
|
|
|
4061
4653
|
### Queries (Read)
|
|
4062
4654
|
|
|
4063
4655
|
```typescript
|
|
4064
|
-
import {
|
|
4656
|
+
import { resolveEntry, resolveEntries, countEntries } from 'includio-cms/admin/remote';
|
|
4065
4657
|
|
|
4066
|
-
//
|
|
4067
|
-
const posts = await
|
|
4068
|
-
|
|
4658
|
+
// List published entries (collection name + locale)
|
|
4659
|
+
const posts = await resolveEntries({
|
|
4660
|
+
collection: 'posts',
|
|
4069
4661
|
status: 'published',
|
|
4070
|
-
|
|
4662
|
+
locale: 'en'
|
|
4071
4663
|
});
|
|
4072
4664
|
|
|
4073
4665
|
// Get single entry by ID
|
|
4074
|
-
const post = await
|
|
4666
|
+
const post = await resolveEntry({ id: 'uuid-here', locale: 'en' });
|
|
4075
4667
|
|
|
4076
|
-
//
|
|
4077
|
-
const
|
|
4078
|
-
slug: 'posts',
|
|
4079
|
-
dataValues: { featured: true, category: 'tech' }
|
|
4080
|
-
});
|
|
4081
|
-
|
|
4082
|
-
// Search in field content (LIKE query)
|
|
4083
|
-
const results = await getEntries({
|
|
4084
|
-
slug: 'posts',
|
|
4085
|
-
dataLike: { title: 'search term' }
|
|
4086
|
-
});
|
|
4668
|
+
// Get a singleton (treated as a one-entry collection)
|
|
4669
|
+
const settings = await resolveEntry({ collection: 'settings', locale: 'en' });
|
|
4087
4670
|
|
|
4088
|
-
//
|
|
4089
|
-
const
|
|
4090
|
-
slug: 'posts',
|
|
4091
|
-
dataILikeOr: { title: 'svelte', description: 'svelte' }
|
|
4092
|
-
});
|
|
4093
|
-
|
|
4094
|
-
// Sort by data field
|
|
4095
|
-
const sorted = await getEntries({
|
|
4096
|
-
slug: 'events',
|
|
4097
|
-
status: 'published',
|
|
4098
|
-
dataOrderBy: { field: 'eventDate', direction: 'asc' }
|
|
4099
|
-
});
|
|
4671
|
+
// Count without populating
|
|
4672
|
+
const total = await countEntries({ collection: 'posts', status: 'published' });
|
|
4100
4673
|
|
|
4101
4674
|
// Pagination
|
|
4102
|
-
const page = await
|
|
4103
|
-
|
|
4675
|
+
const page = await resolveEntries({
|
|
4676
|
+
collection: 'posts',
|
|
4104
4677
|
status: 'published',
|
|
4105
4678
|
limit: 10,
|
|
4106
4679
|
offset: 20,
|
|
4107
4680
|
orderBy: { column: 'createdAt', direction: 'desc' }
|
|
4108
4681
|
});
|
|
4682
|
+
|
|
4683
|
+
// Opt out of populating a relation field — return the raw ID instead
|
|
4684
|
+
const postNoAuthor = await resolveEntry({
|
|
4685
|
+
collection: 'posts',
|
|
4686
|
+
id: 'uuid-here',
|
|
4687
|
+
locale: 'en',
|
|
4688
|
+
populate: { fields: { author: false } }
|
|
4689
|
+
});
|
|
4690
|
+
console.log(postNoAuthor.author); // string ID
|
|
4691
|
+
|
|
4692
|
+
// Cap nested relation depth
|
|
4693
|
+
const flat = await resolveEntry({
|
|
4694
|
+
collection: 'posts',
|
|
4695
|
+
id: 'uuid-here',
|
|
4696
|
+
locale: 'en',
|
|
4697
|
+
populate: { maxDepth: 1 }
|
|
4698
|
+
});
|
|
4109
4699
|
```
|
|
4110
4700
|
|
|
4111
4701
|
### Commands (Write)
|
|
4112
4702
|
|
|
4113
|
-
Commands mutate data and require authentication.
|
|
4703
|
+
Commands mutate data and require admin authentication.
|
|
4114
4704
|
|
|
4115
4705
|
```typescript
|
|
4116
4706
|
import {
|
|
@@ -4121,7 +4711,7 @@ import {
|
|
|
4121
4711
|
deleteEntryCommand
|
|
4122
4712
|
} from 'includio-cms/admin/remote';
|
|
4123
4713
|
|
|
4124
|
-
// Create entry
|
|
4714
|
+
// Create entry (always starts as draft)
|
|
4125
4715
|
await createEntry({ slug: 'posts', type: 'collection' });
|
|
4126
4716
|
|
|
4127
4717
|
// Save as draft
|
|
@@ -4138,7 +4728,7 @@ await updateEntryVersionCommand({
|
|
|
4138
4728
|
type: 'published-now'
|
|
4139
4729
|
});
|
|
4140
4730
|
|
|
4141
|
-
// Unpublish
|
|
4731
|
+
// Unpublish (status returns to draft)
|
|
4142
4732
|
await updateEntryVersionCommand({
|
|
4143
4733
|
entryId: 'uuid',
|
|
4144
4734
|
data: { title: 'Hello' },
|
|
@@ -4151,21 +4741,19 @@ await unarchiveEntryCommand('entry-uuid');
|
|
|
4151
4741
|
await deleteEntryCommand('entry-uuid');
|
|
4152
4742
|
```
|
|
4153
4743
|
|
|
4154
|
-
###
|
|
4744
|
+
### `ResolveEntriesOptions`
|
|
4155
4745
|
|
|
4156
4746
|
```typescript
|
|
4157
|
-
interface
|
|
4747
|
+
interface ResolveEntriesOptions {
|
|
4748
|
+
collection: string; // Collection or single slug
|
|
4749
|
+
locale?: string; // Defaults to cms.languages[0]
|
|
4750
|
+
status?: 'draft' | 'published' | 'scheduled';
|
|
4158
4751
|
ids?: string[];
|
|
4159
|
-
|
|
4160
|
-
dataValues?: Record<string, unknown>; // Exact match
|
|
4161
|
-
dataLike?: Record<string, unknown>; // Partial text match (LIKE)
|
|
4162
|
-
dataILikeOr?: Record<string, unknown>; // Case-insensitive OR search
|
|
4163
|
-
language?: string;
|
|
4164
|
-
status?: 'draft' | 'published' | 'scheduled' | 'archived';
|
|
4752
|
+
filter?: Record<string, unknown>;
|
|
4165
4753
|
orderBy?: { column: 'createdAt' | 'updatedAt' | 'sortOrder'; direction: 'asc' | 'desc' };
|
|
4166
|
-
dataOrderBy?: { field: string; direction: 'asc' | 'desc' };
|
|
4167
4754
|
limit?: number;
|
|
4168
4755
|
offset?: number;
|
|
4756
|
+
populate?: { maxDepth?: number; fields?: Record<string, false> };
|
|
4169
4757
|
}
|
|
4170
4758
|
```
|
|
4171
4759
|
|
|
@@ -4183,7 +4771,7 @@ For external clients. Requires API key authentication.
|
|
|
4183
4771
|
|
|
4184
4772
|
### Authentication
|
|
4185
4773
|
|
|
4186
|
-
Send your API key in the
|
|
4774
|
+
Send your API key in the `Authorization` header:
|
|
4187
4775
|
|
|
4188
4776
|
```
|
|
4189
4777
|
Authorization: Bearer YOUR_API_KEY
|
|
@@ -4192,13 +4780,22 @@ Authorization: Bearer YOUR_API_KEY
|
|
|
4192
4780
|
Configure API keys in your CMS config:
|
|
4193
4781
|
|
|
4194
4782
|
```typescript
|
|
4783
|
+
import { defineConfig, generateApiKey } from 'includio-cms/sveltekit';
|
|
4784
|
+
|
|
4195
4785
|
defineConfig({
|
|
4196
4786
|
apiKeys: [
|
|
4197
|
-
{
|
|
4787
|
+
{
|
|
4788
|
+
key: process.env.API_KEY ?? generateApiKey(),
|
|
4789
|
+
name: 'My App',
|
|
4790
|
+
role: 'editor',
|
|
4791
|
+
expiresAt: '2027-01-01T00:00:00Z' // opt-in expiry
|
|
4792
|
+
}
|
|
4198
4793
|
]
|
|
4199
4794
|
});
|
|
4200
4795
|
```
|
|
4201
4796
|
|
|
4797
|
+
See [Security Model — API Keys](/docs/security#api-keys) for rotation guidance.
|
|
4798
|
+
|
|
4202
4799
|
### Endpoints
|
|
4203
4800
|
|
|
4204
4801
|
All REST endpoints are prefixed with your admin API path (e.g. `/admin/api/rest/`).
|
|
@@ -4241,15 +4838,96 @@ All REST endpoints are prefixed with your admin API path (e.g. `/admin/api/rest/
|
|
|
4241
4838
|
|
|
4242
4839
|
| Method | Path | Description |
|
|
4243
4840
|
|--------|------|-------------|
|
|
4244
|
-
| `POST` | `/upload` | Upload file |
|
|
4841
|
+
| `POST` | `/upload` | Upload file (multipart/form-data) |
|
|
4245
4842
|
| `GET` | `/media/:id` | Get media file metadata |
|
|
4246
4843
|
|
|
4844
|
+
### cURL examples
|
|
4845
|
+
|
|
4846
|
+
List published entries from the `posts` collection:
|
|
4847
|
+
|
|
4848
|
+
```bash
|
|
4849
|
+
curl -s -H "Authorization: Bearer $API_KEY" \
|
|
4850
|
+
"https://your-site.com/admin/api/rest/collections/posts?lang=en&status=published&limit=10"
|
|
4851
|
+
```
|
|
4852
|
+
|
|
4853
|
+
Fetch one entry by ID:
|
|
4854
|
+
|
|
4855
|
+
```bash
|
|
4856
|
+
curl -s -H "Authorization: Bearer $API_KEY" \
|
|
4857
|
+
"https://your-site.com/admin/api/rest/collections/posts/2c1b9a8e-1234-4567-89ab-cdef01234567"
|
|
4858
|
+
```
|
|
4859
|
+
|
|
4860
|
+
Create a draft entry:
|
|
4861
|
+
|
|
4862
|
+
```bash
|
|
4863
|
+
curl -s -X POST \
|
|
4864
|
+
-H "Authorization: Bearer $API_KEY" \
|
|
4865
|
+
-H "Content-Type: application/json" \
|
|
4866
|
+
-d '{"data":{"title":"Hello","slug":"hello"},"lang":"en"}' \
|
|
4867
|
+
"https://your-site.com/admin/api/rest/collections/posts"
|
|
4868
|
+
```
|
|
4869
|
+
|
|
4870
|
+
Update an existing draft:
|
|
4871
|
+
|
|
4872
|
+
```bash
|
|
4873
|
+
curl -s -X PUT \
|
|
4874
|
+
-H "Authorization: Bearer $API_KEY" \
|
|
4875
|
+
-H "Content-Type: application/json" \
|
|
4876
|
+
-d '{"data":{"title":"Hello, world"},"lang":"en"}' \
|
|
4877
|
+
"https://your-site.com/admin/api/rest/collections/posts/2c1b9a8e-1234-4567-89ab-cdef01234567"
|
|
4878
|
+
```
|
|
4879
|
+
|
|
4880
|
+
Publish an entry (per locale):
|
|
4881
|
+
|
|
4882
|
+
```bash
|
|
4883
|
+
curl -s -X POST -H "Authorization: Bearer $API_KEY" \
|
|
4884
|
+
"https://your-site.com/admin/api/rest/entries/2c1b9a8e-1234-4567-89ab-cdef01234567/publish?lang=en"
|
|
4885
|
+
```
|
|
4886
|
+
|
|
4887
|
+
Upload a media file:
|
|
4888
|
+
|
|
4889
|
+
```bash
|
|
4890
|
+
curl -s -X POST \
|
|
4891
|
+
-H "Authorization: Bearer $API_KEY" \
|
|
4892
|
+
-F "file=@./hero.jpg" \
|
|
4893
|
+
"https://your-site.com/admin/api/rest/upload"
|
|
4894
|
+
```
|
|
4895
|
+
|
|
4896
|
+
### Error responses
|
|
4897
|
+
|
|
4898
|
+
REST errors return a JSON body with a stable `code` (`CmsError.code`) plus a human-readable `message`. Match on `code`, not `message`.
|
|
4899
|
+
|
|
4900
|
+
| HTTP | `code` | When |
|
|
4901
|
+
|------|--------|------|
|
|
4902
|
+
| `400` | `INVALID_DATA` | Body fails Zod validation. `details` lists `field.path: message` per issue. |
|
|
4903
|
+
| `400` | `MISSING_REQUIRED_PARAM` | Required query/body param missing (e.g. `collection`). |
|
|
4904
|
+
| `401` | `UNAUTHORIZED` | Missing/expired API key, or `Authorization` header malformed. |
|
|
4905
|
+
| `403` | `FORBIDDEN` | Key role insufficient for operation, or CSRF check failed. |
|
|
4906
|
+
| `404` | `ENTRY_NOT_FOUND` | Resource ID doesn't exist or isn't visible at the requested locale/status. |
|
|
4907
|
+
| `404` | `ENTRY_VERSION_NOT_FOUND` | Entry exists but has no version for that locale/status. |
|
|
4908
|
+
| `409` | `ENTRY_NOT_RETRIABLE` | Operation conflicts with current state (e.g. publishing an archived entry). |
|
|
4909
|
+
| `422` | `CONFIG_VALIDATION_FAILED` | Request implicitly references a misconfigured collection/field. |
|
|
4910
|
+
| `429` | `RATE_LIMITED` | Per-IP limit exceeded. `Retry-After` header included. |
|
|
4911
|
+
| `500` | `INTERNAL_ERROR` | Unhandled error. Check server logs; the response intentionally omits internals. |
|
|
4912
|
+
|
|
4913
|
+
Example error body:
|
|
4914
|
+
|
|
4915
|
+
```json
|
|
4916
|
+
{
|
|
4917
|
+
"code": "INVALID_DATA",
|
|
4918
|
+
"message": "Invalid data: title.length: must be at least 3 characters",
|
|
4919
|
+
"details": [
|
|
4920
|
+
{ "path": "title", "message": "must be at least 3 characters" }
|
|
4921
|
+
]
|
|
4922
|
+
}
|
|
4923
|
+
```
|
|
4924
|
+
|
|
4247
4925
|
## Entity API
|
|
4248
4926
|
|
|
4249
4927
|
Server-side programmatic CRUD — for scripts, migrations, seeds, webhooks.
|
|
4250
4928
|
|
|
4251
4929
|
```typescript
|
|
4252
|
-
import { createEntityAPI } from 'includio-cms/
|
|
4930
|
+
import { createEntityAPI } from 'includio-cms/core';
|
|
4253
4931
|
import { getCMS } from 'includio-cms/core';
|
|
4254
4932
|
|
|
4255
4933
|
const cms = getCMS();
|
|
@@ -4300,6 +4978,8 @@ Options for `create`/`createAndPublish`: `{ skipValidation?, sortOrder?, lang? }
|
|
|
4300
4978
|
|
|
4301
4979
|
> **Delete Restrictions:** Only archived entries can be permanently deleted. Call `archive` first, then `delete`.
|
|
4302
4980
|
|
|
4981
|
+
For error handling patterns (`CmsError`, `instanceof`-based branching), see [Entries — Error handling](/docs/entries#error-handling).
|
|
4982
|
+
|
|
4303
4983
|
|
|
4304
4984
|
---
|
|
4305
4985
|
|
|
@@ -4499,21 +5179,58 @@ For fully custom designs, use `createOrderState` — a reactive Svelte 5 state o
|
|
|
4499
5179
|
|
|
4500
5180
|
# Retry payment
|
|
4501
5181
|
|
|
4502
|
-
When a payment attempt is rejected or left hanging, the customer must be able to try again without going through checkout a second time.
|
|
5182
|
+
When a payment attempt is rejected or left hanging, the customer must be able to try again without going through checkout a second time. This page documents the lifecycle, the API, and the failure modes.
|
|
4503
5183
|
|
|
4504
5184
|
## Lifecycle
|
|
4505
5185
|
|
|
4506
|
-
1.
|
|
4507
|
-
2. Something goes wrong
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
-
|
|
4511
|
-
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
-
|
|
5186
|
+
1. **Checkout completes.** Order created with status `awaitingPayment`. The payment adapter's `createPayment()` is called and `paymentProviderRef` is stored on the order.
|
|
5187
|
+
2. **Something goes wrong:**
|
|
5188
|
+
- Customer abandons the gateway → status stays `awaitingPayment`.
|
|
5189
|
+
- Customer cancels on the gateway → webhook flips status to `paymentRejected`.
|
|
5190
|
+
- Card declined → webhook flips status to `paymentRejected`.
|
|
5191
|
+
- Gateway session expires before payment → status stays `awaitingPayment` indefinitely (no automatic timeout; let the customer retry or the admin cancel).
|
|
5192
|
+
3. **Customer clicks "Zapłać ponownie"** on the token-gated order view.
|
|
5193
|
+
4. **Retry endpoint runs:**
|
|
5194
|
+
- `POST /api/shop/orders/[number]/retry-payment?token=...`
|
|
5195
|
+
- Token validated, status must be `awaitingPayment` or `paymentRejected`.
|
|
5196
|
+
- Adapter's `createPayment()` is called again with the same `OrderRef` — the order, items, totals, and order number are reused.
|
|
5197
|
+
- New `paymentProviderRef` replaces the old one.
|
|
5198
|
+
- If the previous status was `paymentRejected`, status rolls back to `awaitingPayment` (logged in `order_status_history` with `changedBy: 'retry-payment'`).
|
|
5199
|
+
- Response contains `redirectUrl` for the new gateway session.
|
|
5200
|
+
5. **Webhook eventually settles.** New attempt either succeeds (`paid`) or fails again (`paymentRejected`, retry available).
|
|
5201
|
+
|
|
5202
|
+
```
|
|
5203
|
+
┌──────────────────┐ customer cancels ┌──────────────────┐
|
|
5204
|
+
│ awaitingPayment ├────────────────────▶│ paymentRejected │
|
|
5205
|
+
└────────┬─────────┘ └────────┬─────────┘
|
|
5206
|
+
│ │
|
|
5207
|
+
│ retry-payment │ retry-payment
|
|
5208
|
+
│ (new providerRef, same order) │ (rollback to awaitingPayment)
|
|
5209
|
+
▼ ▼
|
|
5210
|
+
┌──────────────────┐ webhook ok ┌──────────────────┐
|
|
5211
|
+
│ paid │◀─────────────────┤ awaitingPayment │
|
|
5212
|
+
└──────────────────┘ └──────────────────┘
|
|
5213
|
+
```
|
|
5214
|
+
|
|
5215
|
+
## Server endpoint
|
|
5216
|
+
|
|
5217
|
+
The endpoint is scaffolded by `includio scaffold admin` into your app. Default location: `src/routes/api/shop/orders/[number]/retry-payment/+server.ts`. It calls `retryPayment(orderNumber, token)` from the shop server module:
|
|
5218
|
+
|
|
5219
|
+
```ts
|
|
5220
|
+
// src/routes/api/shop/orders/[number]/retry-payment/+server.ts
|
|
5221
|
+
import { json, error } from '@sveltejs/kit';
|
|
5222
|
+
import { retryPayment } from 'includio-cms/shop';
|
|
5223
|
+
|
|
5224
|
+
export const POST = async ({ params, url }) => {
|
|
5225
|
+
const token = url.searchParams.get('token');
|
|
5226
|
+
if (!token) throw error(400, 'Missing token');
|
|
5227
|
+
|
|
5228
|
+
const result = await retryPayment(params.number, token);
|
|
5229
|
+
return json(result);
|
|
5230
|
+
};
|
|
5231
|
+
```
|
|
4515
5232
|
|
|
4516
|
-
## SDK
|
|
5233
|
+
## Headless SDK
|
|
4517
5234
|
|
|
4518
5235
|
```ts
|
|
4519
5236
|
import { createShopClient } from 'includio-cms/shop/client';
|
|
@@ -4528,23 +5245,53 @@ if (result.requiresPaymentRedirect && result.redirectUrl) {
|
|
|
4528
5245
|
|
|
4529
5246
|
## With `createOrderState`
|
|
4530
5247
|
|
|
4531
|
-
The headless helper
|
|
5248
|
+
The headless helper exposes a `retry()` method that returns just the redirect URL. You navigate:
|
|
4532
5249
|
|
|
4533
|
-
```
|
|
4534
|
-
|
|
4535
|
-
|
|
5250
|
+
```svelte
|
|
5251
|
+
<button onclick={payAgain} disabled={!order.canRetry}>Zapłać ponownie</button>
|
|
5252
|
+
```
|
|
5253
|
+
|
|
5254
|
+
## Error responses
|
|
5255
|
+
|
|
5256
|
+
The endpoint returns standard HTTP statuses with stable codes (`CmsError.code`). Match on `code`, not message text.
|
|
5257
|
+
|
|
5258
|
+
| HTTP | Code | When |
|
|
5259
|
+
|---|---|---|
|
|
5260
|
+
| `400` | `MISSING_REQUIRED_PARAM` | Order has no payment method, or adapter was removed from config. |
|
|
5261
|
+
| `401` | `UNAUTHORIZED` | Token missing or doesn't match the order. |
|
|
5262
|
+
| `404` | `ENTRY_NOT_FOUND` | Order number doesn't exist. |
|
|
5263
|
+
| `409` | `ORDER_NOT_RETRIABLE` | Status is terminal (`paid`, `done`, `cancelled`) — retry is impossible. |
|
|
5264
|
+
| `429` | `RATE_LIMITED` | Per-IP retry limit exceeded (default 5 retries / hour per IP). |
|
|
5265
|
+
| `502` | `PAYMENT_PROVIDER_ERROR` | Adapter's `createPayment()` threw. Body includes `details.providerMessage` if available. |
|
|
5266
|
+
|
|
5267
|
+
Example error body for a terminal-status retry:
|
|
5268
|
+
|
|
5269
|
+
```json
|
|
5270
|
+
{
|
|
5271
|
+
"code": "ORDER_NOT_RETRIABLE",
|
|
5272
|
+
"message": "Order is already paid; retry is not allowed",
|
|
5273
|
+
"details": { "currentStatus": "paid" }
|
|
5274
|
+
}
|
|
4536
5275
|
```
|
|
4537
5276
|
|
|
4538
5277
|
## Constraints
|
|
4539
5278
|
|
|
4540
|
-
-
|
|
4541
|
-
-
|
|
4542
|
-
-
|
|
4543
|
-
-
|
|
5279
|
+
- **Same order, same totals.** Retry never recalculates prices, taxes, or shipping. If you need a price update, cancel and let the customer re-checkout.
|
|
5280
|
+
- **Token-gated.** The token comes from the order confirmation email. Without it, the endpoint returns 401 even for a logged-in customer — by design (orders are guest-flow first).
|
|
5281
|
+
- **Rate-limited per IP.** Default 5 retries per hour. Override with `INCLUDIO_FORM_RATE_LIMIT_*` env vars or a custom `rateLimitGuard` store. See [Security Model — Rate Limiting](/docs/security#rate-limiting).
|
|
5282
|
+
- **Idempotent at the gateway.** Each retry produces a new `paymentProviderRef`. Old refs are not cancelled at the gateway — they expire on their own (provider-specific, typically 30 min).
|
|
5283
|
+
- **No automatic retries.** The system never retries on the customer's behalf. If a webhook is missed, the customer must click again. Background polling is a v1.x roadmap item.
|
|
4544
5284
|
|
|
4545
5285
|
## Admin override
|
|
4546
5286
|
|
|
4547
|
-
If
|
|
5287
|
+
If a customer is stuck (e.g. webhook lost, retry limit hit, gateway down), an admin can manually move the order from the **Orders → detail** page:
|
|
5288
|
+
|
|
5289
|
+
- **Mark as paid** — fires the same status-change email + invoice flow as a real success. Use only when payment was confirmed out-of-band.
|
|
5290
|
+
- **Cancel** — releases stock reservation, sends cancellation email, status `cancelled` (terminal). Customer cannot retry afterwards.
|
|
5291
|
+
|
|
5292
|
+
Status changes from the admin are logged in `order_status_history` with `changedBy: <admin user id>` for audit.
|
|
5293
|
+
|
|
5294
|
+
> **Retry vs new order:** Retry reuses the order. If the customer wants to change items, addresses, or shipping method, cancel the order and start checkout again. There's no "edit then retry" flow — by design, to keep the audit trail clean.
|
|
4548
5295
|
|
|
4549
5296
|
|
|
4550
5297
|
---
|
|
@@ -4666,6 +5413,118 @@ Tracking number is updated whenever the webhook payload contains a non-null `tra
|
|
|
4666
5413
|
`OrderStatus.svelte` (the built-in component) renders a "Śledzenie przesyłki" section automatically when the order has a `trackingNumber`. If you wrote a custom order view, read `order.trackingNumber` and `order.trackingUrl` from the order endpoint response and render them yourself.
|
|
4667
5414
|
|
|
4668
5415
|
|
|
5416
|
+
---
|
|
5417
|
+
|
|
5418
|
+
# Stability Promise
|
|
5419
|
+
|
|
5420
|
+
What's stable, what may change, and how breaking changes are introduced. Use this as the contract between Includio CMS and your code.
|
|
5421
|
+
|
|
5422
|
+
## Semantic Versioning
|
|
5423
|
+
|
|
5424
|
+
Includio CMS follows [Semantic Versioning 2.0](https://semver.org/) starting with `1.0.0`:
|
|
5425
|
+
|
|
5426
|
+
| Bump | Trigger | Example |
|
|
5427
|
+
|---|---|---|
|
|
5428
|
+
| **MAJOR** (`1.0.0 → 2.0.0`) | Removed/renamed `@public` symbol, changed function signature, removed config option | Renaming `resolveEntry({ collection })` to `resolveEntry({ table })` |
|
|
5429
|
+
| **MINOR** (`1.0.0 → 1.1.0`) | New `@public` symbol, additive option, new optional adapter method | Adding `resolveEntries({ ..., search? })` |
|
|
5430
|
+
| **PATCH** (`1.0.0 → 1.0.1`) | Bug fix, internal refactor, doc/typing fix | Fixing a regression in `resolveEntry` cycle detection |
|
|
5431
|
+
|
|
5432
|
+
Pre-1.0 releases (`0.x`) made breaking changes in any version — that ends with `1.0.0`.
|
|
5433
|
+
|
|
5434
|
+
## API tags — three tiers of stability
|
|
5435
|
+
|
|
5436
|
+
Every public symbol carries one of three JSDoc tags. The tag tells you how aggressively you can rely on it.
|
|
5437
|
+
|
|
5438
|
+
> **Where to look:** Tags are emitted by `pnpm api:generate` into `API.md` (single source of truth for the public surface). Each entry on `API.md` shows its tag.
|
|
5439
|
+
|
|
5440
|
+
### `@public` — semver-protected
|
|
5441
|
+
|
|
5442
|
+
Stable. Will not break inside a major version. Removal or signature change requires a MAJOR bump and a migration guide entry.
|
|
5443
|
+
|
|
5444
|
+
```ts
|
|
5445
|
+
/** @public */
|
|
5446
|
+
export function resolveEntry(opts: ResolveEntryOptions): Promise<Entry | null>;
|
|
5447
|
+
```
|
|
5448
|
+
|
|
5449
|
+
Use freely in production code. Examples: `defineConfig`, `resolveEntry`, `resolveEntries`, `getCMS`, all `define*` helpers, all adapter factories (`pg`, `local`, `nodemailerAdapter`, `openAIAdapter`, `claudeAdapter`).
|
|
5450
|
+
|
|
5451
|
+
### `@experimental` — opt-in to change
|
|
5452
|
+
|
|
5453
|
+
May change without a MAJOR bump. Ships in a MINOR or PATCH if usage feedback prompts a redesign. Always documented in `CHANGELOG.md` even when it changes.
|
|
5454
|
+
|
|
5455
|
+
```ts
|
|
5456
|
+
/** @experimental Plugin hooks API — signature may change before 2.0. */
|
|
5457
|
+
export interface PluginHooks { /* ... */ }
|
|
5458
|
+
```
|
|
5459
|
+
|
|
5460
|
+
Use in production at your discretion. Pin the patch version (e.g. `"includio-cms": "1.4.7"` instead of `"^1.4.0"`) if you want to lock against silent changes.
|
|
5461
|
+
|
|
5462
|
+
### `@internal` — outside the contract
|
|
5463
|
+
|
|
5464
|
+
Not part of the public surface. May change in any release. **Do not import directly** — TypeScript will allow it, but `API.md` does not list these and CHANGELOG does not announce changes.
|
|
5465
|
+
|
|
5466
|
+
```ts
|
|
5467
|
+
/** @internal */
|
|
5468
|
+
export function _populate(/* ... */) { /* ... */ }
|
|
5469
|
+
```
|
|
5470
|
+
|
|
5471
|
+
Internal symbols use a leading underscore by convention (`_resolveEntry`, `_getRawEntry`).
|
|
5472
|
+
|
|
5473
|
+
## Deprecation timeline
|
|
5474
|
+
|
|
5475
|
+
When a `@public` symbol needs to go away, the deprecation flow is:
|
|
5476
|
+
|
|
5477
|
+
1. **MINOR release** — symbol is annotated `@deprecated` in JSDoc and continues to work. CHANGELOG calls out the deprecation and points to the replacement.
|
|
5478
|
+
2. **At least one MINOR cycle later** — the symbol can be removed in the next MAJOR release. Migration guide entry is mandatory.
|
|
5479
|
+
|
|
5480
|
+
```ts
|
|
5481
|
+
/**
|
|
5482
|
+
* @public
|
|
5483
|
+
* @deprecated Since 1.4.0. Use `resolveEntry({ collection })` instead. Removed in 2.0.0.
|
|
5484
|
+
*/
|
|
5485
|
+
export function getEntry(opts: GetEntryOptions): Promise<Entry | null>;
|
|
5486
|
+
```
|
|
5487
|
+
|
|
5488
|
+
> **Pre-1.0 exception:** Some breaking changes between 0.16.0 and 0.22.0 were introduced as **hard removes** (no deprecated wrapper) — consistent with the lean v1.0 policy of "all breaking before v1.0". See the [Migration Guide](/docs/migration) for find-replace patterns.
|
|
5489
|
+
|
|
5490
|
+
## What is _not_ part of the contract
|
|
5491
|
+
|
|
5492
|
+
These can change in any release without notice:
|
|
5493
|
+
|
|
5494
|
+
- File paths under `src/lib/**` (only `package.json` exports map is contractual)
|
|
5495
|
+
- Internal helpers with `_` prefix or `@internal` JSDoc
|
|
5496
|
+
- Database migrations between minor versions (run `pnpm db:push` after upgrading)
|
|
5497
|
+
- The exact wording of error messages — only `CmsError.code` is stable
|
|
5498
|
+
- HTML structure of admin UI (admin is a finished surface, not a customizable theme)
|
|
5499
|
+
- Implementation details of bundled adapters (`db-postgres`, `files-local`, etc.) — only the `DatabaseAdapter` / `FilesAdapter` interfaces are contractual
|
|
5500
|
+
|
|
5501
|
+
## How to consume Includio CMS safely
|
|
5502
|
+
|
|
5503
|
+
```json
|
|
5504
|
+
{
|
|
5505
|
+
"dependencies": {
|
|
5506
|
+
"includio-cms": "^1.0.0"
|
|
5507
|
+
}
|
|
5508
|
+
}
|
|
5509
|
+
```
|
|
5510
|
+
|
|
5511
|
+
- `^1.0.0` accepts all PATCH and MINOR. Never accepts MAJOR.
|
|
5512
|
+
- Pin to exact `1.0.0` if relying on `@experimental` symbols.
|
|
5513
|
+
- After `pnpm update`, run `pnpm db:push` and re-run integration tests.
|
|
5514
|
+
|
|
5515
|
+
## Reference: public surface
|
|
5516
|
+
|
|
5517
|
+
The current public surface is auto-generated and lives in `API.md` at the repo root. Browse it before relying on a symbol — if it's not there, it's not `@public`.
|
|
5518
|
+
|
|
5519
|
+
```bash
|
|
5520
|
+
pnpm api:generate
|
|
5521
|
+
```
|
|
5522
|
+
|
|
5523
|
+
Outputs a tree of every exported symbol grouped by entry point (`includio-cms`, `includio-cms/core`, `includio-cms/sveltekit`, `includio-cms/db-postgres`, etc.) with its tag.
|
|
5524
|
+
|
|
5525
|
+
See also: [Migration Guide](/docs/migration), [Adapter Contracts](/docs/adapters).
|
|
5526
|
+
|
|
5527
|
+
|
|
4669
5528
|
---
|
|
4670
5529
|
|
|
4671
5530
|
# Migration Guide
|
|
@@ -4685,6 +5544,170 @@ Version-by-version upgrade guide. Each section lists what changed, what to do, a
|
|
|
4685
5544
|
|
|
4686
5545
|
---
|
|
4687
5546
|
|
|
5547
|
+
## Migrating from 0.x to 1.0 — master cheatsheet
|
|
5548
|
+
|
|
5549
|
+
If you're jumping from 0.15.x straight to a v1.0 release, this section is the consolidated set of breaking changes between 0.16.0 and 0.22.0. Sections below the cheatsheet cover the older 0.7–0.15 changes for reference.
|
|
5550
|
+
|
|
5551
|
+
### Global find-replace (0.18.0)
|
|
5552
|
+
|
|
5553
|
+
The single biggest churn is the entry resolver rename. Run these find-replace passes across your project:
|
|
5554
|
+
|
|
5555
|
+
| Find | Replace |
|
|
5556
|
+
|---|---|
|
|
5557
|
+
| `getEntry({ slug:` | `resolveEntry({ collection:` |
|
|
5558
|
+
| `getEntries({ slug:` | `resolveEntries({ collection:` |
|
|
5559
|
+
| `language:` (inside resolver options) | `locale:` |
|
|
5560
|
+
| `import { getEntry, getEntries } from 'includio-cms` | `import { resolveEntry, resolveEntries, countEntries } from 'includio-cms` |
|
|
5561
|
+
| `getEntryOrThrow(opts)` | `const e = await resolveEntry(opts); if (!e) throw new Error('Entry not found');` |
|
|
5562
|
+
|
|
5563
|
+
`status: 'archived'` is no longer valid in resolvers — call `archiveEntryCommand(id)` / `unarchiveEntryCommand(id)` instead.
|
|
5564
|
+
|
|
5565
|
+
### 0.16.0 — Hard reset
|
|
5566
|
+
|
|
5567
|
+
**Breaking.** Removed unused prototypes: `src/lib/inline-edit-proto/*`, `src/lib/demo/seed.ts`, demo routes (`/demo/*`, `/admin/demo/*`), `ROADMAP-EDITOR.md`. None of these were ever in `package.json` exports — if your code didn't import them, no action needed.
|
|
5568
|
+
|
|
5569
|
+
### 0.18.0 — Entry resolver consolidation
|
|
5570
|
+
|
|
5571
|
+
**Breaking.** Hard-removed from public API: `getEntry`, `getEntries`, `getEntryOrThrow`, `getRawEntry`, `getRawEntries`, `getRawEntryOrThrow`, `getDbEntry`, `getDbEntries`, `populateEntryData`. Replaced by canonical `resolveEntry` / `resolveEntries` / `countEntries`.
|
|
5572
|
+
|
|
5573
|
+
What changed in the call shape:
|
|
5574
|
+
|
|
5575
|
+
| Old | New |
|
|
5576
|
+
|---|---|
|
|
5577
|
+
| `getEntry({ slug, id, language, status })` | `resolveEntry({ collection: slug, id, locale: language, status })` |
|
|
5578
|
+
| `getEntries({ slug, language, status, ids?, limit?, offset? })` | `resolveEntries({ collection: slug, locale: language, status, ids?, limit?, offset? })` |
|
|
5579
|
+
| `populateEntryData(...)` | INTERNAL — admin preview uses internal helpers |
|
|
5580
|
+
|
|
5581
|
+
Other behavior changes:
|
|
5582
|
+
|
|
5583
|
+
- `status` enum narrowed: removed `'archived'`. Use archive operations instead.
|
|
5584
|
+
- `PopulateConfig` is now an explicit option for opt-out (`populate: { fields: { author: false } }`) and depth caps (`populate: { maxDepth: 1 }`). Default is full populate with `maxDepth: 5`.
|
|
5585
|
+
- Plugin `populateResolver(value, field)` got a third arg: `populateResolver(value, field, ctx: PopulateCtx)`. `ctx` carries locale, status, depth, visited, populate config, entryId. Add the third arg even if you ignore it.
|
|
5586
|
+
|
|
5587
|
+
```typescript
|
|
5588
|
+
// Before (0.16)
|
|
5589
|
+
import { getEntry, getEntries } from 'includio-cms/server';
|
|
5590
|
+
const post = await getEntry({ slug: 'posts', id: postId, language: 'pl' });
|
|
5591
|
+
const all = await getEntries({ slug: 'posts', language: 'pl', limit: 10 });
|
|
5592
|
+
|
|
5593
|
+
// After (0.18+)
|
|
5594
|
+
import { resolveEntry, resolveEntries } from 'includio-cms/server';
|
|
5595
|
+
const post = await resolveEntry({ collection: 'posts', id: postId, locale: 'pl' });
|
|
5596
|
+
const all = await resolveEntries({ collection: 'posts', locale: 'pl', limit: 10 });
|
|
5597
|
+
```
|
|
5598
|
+
|
|
5599
|
+
### 0.19.0 — Adapter peers go optional
|
|
5600
|
+
|
|
5601
|
+
**Breaking.** `@anthropic-ai/sdk`, `nodemailer`, `openai` moved from `dependencies` to `peerDependenciesMeta.optional`. `runed` changed from optional to **required** peer.
|
|
5602
|
+
|
|
5603
|
+
What you need to do:
|
|
5604
|
+
|
|
5605
|
+
```bash
|
|
5606
|
+
# Required for everyone (now hard-required peer)
|
|
5607
|
+
pnpm add runed
|
|
5608
|
+
|
|
5609
|
+
# Install only if you use the matching adapter
|
|
5610
|
+
pnpm add nodemailer # email-nodemailer
|
|
5611
|
+
pnpm add openai # ai-openai
|
|
5612
|
+
pnpm add @anthropic-ai/sdk # ai-claude
|
|
5613
|
+
```
|
|
5614
|
+
|
|
5615
|
+
Adapter factories (`pg`, `local`, `nodemailerAdapter`, `openAIAdapter`, `claudeAdapter`) are now `@public` and stable. SDK imports inside the adapters are lazy — installing the SDK is only required when the adapter is actually used at runtime.
|
|
5616
|
+
|
|
5617
|
+
### 0.20.0 — API surface lock
|
|
5618
|
+
|
|
5619
|
+
**Breaking.** Package exports trimmed from 26 entry points to 16. JSDoc tags (`@public` / `@experimental` / `@internal`) added across the public API. Auto-generated `API.md` is the new single source of truth.
|
|
5620
|
+
|
|
5621
|
+
Most-affected import paths (full list of 10 removed/merged paths in [API.md](../API.md)):
|
|
5622
|
+
|
|
5623
|
+
| Removed/changed | Replacement |
|
|
5624
|
+
|---|---|
|
|
5625
|
+
| `includio-cms/admin/*` (wildcard) | `includio-cms/admin/client`, `includio-cms/admin/remote` |
|
|
5626
|
+
| `includio-cms/auth` | `includio-cms/core` |
|
|
5627
|
+
| `includio-cms/entity` | `includio-cms/core` |
|
|
5628
|
+
| `includio-cms/shop/svelte` | `includio-cms/shop/client` |
|
|
5629
|
+
| `includio-cms/db-postgres/schema-*` | `includio-cms/db-postgres` |
|
|
5630
|
+
| `includio-cms/admin/api/*` | (internal — removed) |
|
|
5631
|
+
| `includio-cms/updates` | (internal — removed) |
|
|
5632
|
+
|
|
5633
|
+
Run `pnpm check` after upgrading — TypeScript flags every stale import.
|
|
5634
|
+
|
|
5635
|
+
### 0.21.0 — Security hardening
|
|
5636
|
+
|
|
5637
|
+
**Feature.** No code changes required to consume the new defaults, but you should review them:
|
|
5638
|
+
|
|
5639
|
+
- API keys gain optional `expiresAt` (ISO-8601) and audit-only `rotatedAt`. Without `expiresAt`, the key never expires — opt in if you want rotation.
|
|
5640
|
+
- Sharp calls (image processing) wrapped in 30-second timeout. Override with `INCLUDIO_SHARP_TIMEOUT_MS`.
|
|
5641
|
+
- Form submission rate limit refactored to use shared `MemoryRateLimitStore`. Configure with `INCLUDIO_FORM_RATE_LIMIT_MAX` / `INCLUDIO_FORM_RATE_LIMIT_WINDOW_MS`.
|
|
5642
|
+
- New file at repo root: `KNOWN-RISKS.md` — five accepted security trade-offs (CSP `'unsafe-inline'`, opt-in API key rotation, in-memory rate-limit store, Sharp timeout limits, ffmpeg/sharp CLI args). Read it before deploying to compliance-sensitive environments.
|
|
5643
|
+
|
|
5644
|
+
See [Security Model](/docs/security) for the full picture.
|
|
5645
|
+
|
|
5646
|
+
### 0.22.0 — DX & config validation
|
|
5647
|
+
|
|
5648
|
+
**Breaking.** `defineConfig()` now runs strict Zod validation at boot. Configs that previously "worked" with subtle bugs throw `ConfigValidationError` on startup. Resolver and write operations throw typed `CmsError` (with stable `code` + `context`) instead of generic `Error`.
|
|
5649
|
+
|
|
5650
|
+
Common config validation failures and fixes:
|
|
5651
|
+
|
|
5652
|
+
- **Duplicate slug** — two collections (or singles, or forms) share a slug → make each unique within its category.
|
|
5653
|
+
- **Invalid language code** — `'ENGLISH'` → `'en'` (ISO-639-1, optionally with region: `'pl-PL'`).
|
|
5654
|
+
- **Multiple default locales** — only one `{ default: true }` allowed.
|
|
5655
|
+
- **Missing adapter method** — partial adapter stub → import the full `pg()` / `local()` or implement every required method.
|
|
5656
|
+
- **Invalid relation target** — `{ type: 'relation', collection: 'authors' }` while no `authors` collection exists → declare it or fix the target.
|
|
5657
|
+
|
|
5658
|
+
Replace string-matching error catches with `code` matching:
|
|
5659
|
+
|
|
5660
|
+
```typescript
|
|
5661
|
+
// Before
|
|
5662
|
+
catch (e) {
|
|
5663
|
+
if ((e as Error).message === 'Entry not found') { /* 404 */ }
|
|
5664
|
+
}
|
|
5665
|
+
|
|
5666
|
+
// After
|
|
5667
|
+
import { CmsError } from 'includio-cms';
|
|
5668
|
+
catch (e) {
|
|
5669
|
+
if (e instanceof CmsError && e.code === 'ENTRY_NOT_FOUND') { /* 404 */ }
|
|
5670
|
+
}
|
|
5671
|
+
```
|
|
5672
|
+
|
|
5673
|
+
Stable `CmsError.code` values: `ENTRY_NOT_FOUND`, `ENTRY_VERSION_NOT_FOUND`, `INVALID_DATA`, `MISSING_REQUIRED_PARAM`, `CONFIG_VALIDATION_FAILED`. See [Entries — Error handling](/docs/entries#error-handling).
|
|
5674
|
+
|
|
5675
|
+
`.env.example` was reformatted with all `INCLUDIO_*` env vars (sharp timeout, rate limits, CSRF allowed origins). The demo-only `DEMO_USER_*` vars were removed.
|
|
5676
|
+
|
|
5677
|
+
### Suggested upgrade path
|
|
5678
|
+
|
|
5679
|
+
If you're on 0.15.x and want to land on the v1.0 release in one go:
|
|
5680
|
+
|
|
5681
|
+
```bash
|
|
5682
|
+
# 1. Upgrade dependencies (pnpm flags missing peers)
|
|
5683
|
+
pnpm add includio-cms@latest
|
|
5684
|
+
pnpm add runed # required peer since 0.19.0
|
|
5685
|
+
pnpm add nodemailer openai # if you use them
|
|
5686
|
+
|
|
5687
|
+
# 2. Run TypeScript — fixes most breakage
|
|
5688
|
+
pnpm check
|
|
5689
|
+
# Fix every error: it's mechanical (find/replace patterns above)
|
|
5690
|
+
|
|
5691
|
+
# 3. Sync DB
|
|
5692
|
+
pnpm db:push
|
|
5693
|
+
|
|
5694
|
+
# 4. Run integration tests
|
|
5695
|
+
pnpm test
|
|
5696
|
+
|
|
5697
|
+
# 5. Boot the dev server — strict config validation will surface any latent issues
|
|
5698
|
+
pnpm dev
|
|
5699
|
+
```
|
|
5700
|
+
|
|
5701
|
+
If `defineConfig` throws at boot, read the bullet list — every issue has a `path` (e.g. `collections[1].slug`) and a `Hint:` line. Fix top-down; one issue often reveals the next.
|
|
5702
|
+
|
|
5703
|
+
---
|
|
5704
|
+
|
|
5705
|
+
## Pre-v1.0 deprecations and changes
|
|
5706
|
+
|
|
5707
|
+
The sections below cover changes between 0.7 and 0.15. Useful only if you're on a much older release; otherwise skip.
|
|
5708
|
+
|
|
5709
|
+
---
|
|
5710
|
+
|
|
4688
5711
|
## New in 0.14.0 — Video Transcoding
|
|
4689
5712
|
|
|
4690
5713
|
**Added:** Auto-transcode videos to mp4/webm, system info, disk usage.
|