Skip to content

Commit c579ee8

Browse files
committed
feat(m1.1): enforce canonical slug and content identity policy
1 parent 3f5dbfa commit c579ee8

8 files changed

Lines changed: 339 additions & 36 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ npm run dev
4343

4444
The tests create, destroy, and manipulate Git repositories. Running low-level plumbing commands on your host filesystem is risky - a typo could affect your local Git setup. That's why we built Docker isolation into everything.
4545

46-
**Read more:** [TESTING_GUIDE.md](./TESTING_GUIDE.md) | [docs/GETTING_STARTED.md](./docs/GETTING_STARTED.md)
46+
**Read more:** [TESTING_GUIDE.md](./TESTING_GUIDE.md) | [docs/GETTING_STARTED.md](./docs/GETTING_STARTED.md) | [docs/CONTENT_ID_POLICY.md](./docs/CONTENT_ID_POLICY.md)
4747

4848
## Features
4949

bin/git-cms.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env node
22

33
import CmsService from '../src/lib/CmsService.js';
4+
import { canonicalizeSlug } from '../src/lib/ContentIdentityPolicy.js';
45

56
async function main() {
67
const [,, cmd, ...args] = process.argv;
@@ -12,8 +13,9 @@ async function main() {
1213
try {
1314
switch (cmd) {
1415
case 'draft': {
15-
const [slug, title] = args;
16-
if (!slug || !title) throw new Error('Usage: git cms draft <slug> "Title" < content.md');
16+
const [rawSlug, title] = args;
17+
if (!rawSlug || !title) throw new Error('Usage: git cms draft <slug> "Title" < content.md');
18+
const slug = canonicalizeSlug(rawSlug);
1719

1820
const chunks = [];
1921
for await (const chunk of process.stdin) chunks.push(chunk);
@@ -24,8 +26,9 @@ async function main() {
2426
break;
2527
}
2628
case 'publish': {
27-
const [slug] = args;
28-
if (!slug) throw new Error('Usage: git cms publish <slug>');
29+
const [rawSlug] = args;
30+
if (!rawSlug) throw new Error('Usage: git cms publish <slug>');
31+
const slug = canonicalizeSlug(rawSlug);
2932

3033
const res = await cms.publishArticle({ slug });
3134
console.log(`Published: ${res.sha} (${res.ref})`);
@@ -38,8 +41,9 @@ async function main() {
3841
break;
3942
}
4043
case 'show': {
41-
const [slug] = args;
42-
if (!slug) throw new Error('Usage: git cms show <slug>');
44+
const [rawSlug] = args;
45+
if (!rawSlug) throw new Error('Usage: git cms show <slug>');
46+
const slug = canonicalizeSlug(rawSlug);
4347
const article = await cms.readArticle({ slug });
4448
console.log(`# ${article.title}\n\n${article.body}`);
4549
break;

docs/CONTENT_ID_POLICY.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Content Identity Policy (v1.0.0)
2+
3+
Status: Active
4+
Effective Date: 2026-02-11
5+
Applies To: CLI, HTTP API, `CmsService`
6+
7+
## 1. Purpose
8+
9+
Define one canonical contract for content identity so every ingress path behaves the same.
10+
11+
In v1, `contentId` is slug-backed:
12+
13+
- Canonical `contentId` = canonical `slug`
14+
- `contentId` is immutable for a given slug lineage
15+
16+
## 2. Canonical Slug Rules
17+
18+
A slug is canonicalized with:
19+
20+
1. Unicode normalization: `NFKC`
21+
2. Trim surrounding whitespace
22+
3. Lowercase
23+
24+
After canonicalization, it must satisfy:
25+
26+
- Length: `1..64`
27+
- Pattern: `^[a-z0-9]+(?:-[a-z0-9]+)*$`
28+
- Reserved values forbidden
29+
30+
Reserved slugs:
31+
32+
- `.`, `..`
33+
- `admin`, `api`, `assets`, `chunks`
34+
- `draft`, `new`, `published`
35+
- `refs`, `root`
36+
37+
## 3. Kind Rules
38+
39+
Allowed content kinds:
40+
41+
- `articles`
42+
- `published`
43+
- `comments`
44+
45+
Any other kind is rejected.
46+
47+
## 4. Collision Policy
48+
49+
Because slugs are canonicalized before use, values that normalize to the same slug are the same identity.
50+
51+
Examples:
52+
53+
- `Hello-World` -> `hello-world`
54+
- ` hello-world ` -> `hello-world`
55+
56+
Both target the same refs and same logical content lineage.
57+
58+
## 5. contentId Semantics
59+
60+
During snapshot save:
61+
62+
- If no `trailers.contentId`/`trailers.contentid` is provided, system writes canonical slug as trailer `contentid`.
63+
- If `trailers.contentId` or `trailers.contentid` is provided, it must canonicalize to the same value as `slug`.
64+
- Mismatches are rejected.
65+
66+
## 6. Rename Semantics (v1)
67+
68+
Explicit rename is not implemented as a first-class operation yet.
69+
70+
Operationally in v1:
71+
72+
- A new slug creates/targets a different identity.
73+
- Moving history between slugs is out of scope for this policy and will be addressed with state-machine work (`M1.2+`).
74+
75+
## 7. Error Contract
76+
77+
Validation failures return `CmsValidationError` with:
78+
79+
- `error`: human-readable message
80+
- `code`: machine-friendly code (`slug_invalid_format`, `slug_reserved`, etc.)
81+
- `field`: offending field (`slug`, `contentId`, `kind`, ...)
82+
83+
HTTP API maps validation failures to `400`.

src/lib/CmsService.js

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { createMessageHelpers } from '@git-stunts/trailer-codec';
44
import ContentAddressableStore from '@git-stunts/git-cas';
55
import VaultResolver from './VaultResolver.js';
66
import ShellRunner from '@git-stunts/plumbing/ShellRunner';
7+
import {
8+
canonicalizeKind,
9+
canonicalizeSlug,
10+
resolveContentIdentity,
11+
} from './ContentIdentityPolicy.js';
712

813
/**
914
* @typedef {Object} CmsServiceOptions
@@ -43,14 +48,17 @@ export default class CmsService {
4348
* @private
4449
*/
4550
_refFor(slug, kind = 'articles') {
46-
return `${this.refPrefix}/${kind}/${slug}`;
51+
const canonicalSlug = canonicalizeSlug(slug);
52+
const canonicalKind = canonicalizeKind(kind);
53+
return `${this.refPrefix}/${canonicalKind}/${canonicalSlug}`;
4754
}
4855

4956
/**
5057
* Lists all articles of a certain kind.
5158
*/
5259
async listArticles({ kind = 'articles' } = {}) {
53-
const ns = `${this.refPrefix}/${kind}/`;
60+
const canonicalKind = canonicalizeKind(kind);
61+
const ns = `${this.refPrefix}/${canonicalKind}/`;
5462
let out = '';
5563
try {
5664
out = await this.plumbing.execute({ args: ['for-each-ref', ns, '--format=%(refname) %(objectname)'] });
@@ -84,10 +92,17 @@ export default class CmsService {
8492
* Saves a new version (snapshot) of an article.
8593
*/
8694
async saveSnapshot({ slug, title, body, trailers = {} }) {
87-
const ref = this._refFor(slug, 'articles');
95+
const safeTrailers = trailers && typeof trailers === 'object' ? trailers : {};
96+
const identity = resolveContentIdentity({ slug, trailers: safeTrailers });
97+
const ref = this._refFor(identity.slug, 'articles');
8898
const parentSha = await this.graph.readRef(ref);
8999

90-
const finalTrailers = { ...trailers, status: 'draft', updatedAt: new Date().toISOString() };
100+
const finalTrailers = {
101+
...safeTrailers,
102+
contentid: identity.contentId,
103+
status: 'draft',
104+
updatedAt: new Date().toISOString(),
105+
};
91106
const message = this.codec.encode({ title, body, trailers: finalTrailers });
92107

93108
const newSha = await this.graph.commitNode({
@@ -104,11 +119,12 @@ export default class CmsService {
104119
* Publishes an article by fast-forwarding the 'published' ref.
105120
*/
106121
async publishArticle({ slug, sha }) {
107-
const draftRef = this._refFor(slug, 'articles');
108-
const pubRef = this._refFor(slug, 'published');
122+
const canonicalSlug = canonicalizeSlug(slug);
123+
const draftRef = this._refFor(canonicalSlug, 'articles');
124+
const pubRef = this._refFor(canonicalSlug, 'published');
109125

110126
const targetSha = sha || await this.graph.readRef(draftRef);
111-
if (!targetSha) throw new Error(`Nothing to publish for ${slug}`);
127+
if (!targetSha) throw new Error(`Nothing to publish for ${canonicalSlug}`);
112128

113129
const oldSha = await this.graph.readRef(pubRef);
114130
await this.repo.updateRef({ ref: pubRef, newSha: targetSha, oldSha });
@@ -120,6 +136,7 @@ export default class CmsService {
120136
* Uploads an asset and returns its manifest and CAS info.
121137
*/
122138
async uploadAsset({ slug, filePath, filename }) {
139+
const canonicalSlug = canonicalizeSlug(slug);
123140
const ENV = (process.env.GIT_CMS_ENV || 'dev').toLowerCase();
124141
const encryptionKeyRaw = await this.vault.resolveSecret({
125142
envKey: 'CHUNK_ENC_KEY',
@@ -130,14 +147,14 @@ export default class CmsService {
130147

131148
const manifest = await this.cas.storeFile({
132149
filePath,
133-
slug,
150+
slug: canonicalSlug,
134151
filename,
135152
encryptionKey
136153
});
137154

138155
const treeOid = await this.cas.createTree({ manifest });
139156

140-
const ref = `refs/_blog/chunks/${slug}@current`;
157+
const ref = `refs/_blog/chunks/${canonicalSlug}@current`;
141158
const commitSha = await this.graph.commitNode({
142159
message: `asset:${filename}\n\nmanifest: ${treeOid}`,
143160
});

src/lib/ContentIdentityPolicy.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Canonical content identity policy for git-cms.
3+
*
4+
* Content IDs are slug-backed in v1: the canonical slug is also the contentId.
5+
*/
6+
7+
export class CmsValidationError extends Error {
8+
constructor(message, { code = 'validation_error', field = 'input' } = {}) {
9+
super(message);
10+
this.name = 'CmsValidationError';
11+
this.code = code;
12+
this.field = field;
13+
}
14+
}
15+
16+
export const CONTENT_ID_POLICY_VERSION = '1.0.0';
17+
export const SLUG_MIN_LENGTH = 1;
18+
export const SLUG_MAX_LENGTH = 64;
19+
20+
const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
21+
const RESERVED_SLUGS = new Set([
22+
'.',
23+
'..',
24+
'admin',
25+
'api',
26+
'assets',
27+
'chunks',
28+
'draft',
29+
'new',
30+
'published',
31+
'refs',
32+
'root',
33+
]);
34+
35+
const ALLOWED_KINDS = new Set(['articles', 'published', 'comments']);
36+
37+
function asString(value, field) {
38+
if (typeof value !== 'string') {
39+
throw new CmsValidationError(`${field} must be a string`, {
40+
code: 'invalid_type',
41+
field,
42+
});
43+
}
44+
return value;
45+
}
46+
47+
export function canonicalizeSlug(input, { field = 'slug' } = {}) {
48+
const value = asString(input, field).normalize('NFKC').trim().toLowerCase();
49+
50+
if (value.length < SLUG_MIN_LENGTH) {
51+
throw new CmsValidationError(`${field} cannot be empty`, {
52+
code: 'slug_empty',
53+
field,
54+
});
55+
}
56+
57+
if (value.length > SLUG_MAX_LENGTH) {
58+
throw new CmsValidationError(
59+
`${field} must be ${SLUG_MAX_LENGTH} characters or fewer`,
60+
{ code: 'slug_too_long', field }
61+
);
62+
}
63+
64+
if (!SLUG_REGEX.test(value)) {
65+
throw new CmsValidationError(
66+
`${field} must match ${SLUG_REGEX} (lowercase letters, numbers, single hyphens)`,
67+
{ code: 'slug_invalid_format', field }
68+
);
69+
}
70+
71+
if (RESERVED_SLUGS.has(value)) {
72+
throw new CmsValidationError(`${field} "${value}" is reserved`, {
73+
code: 'slug_reserved',
74+
field,
75+
});
76+
}
77+
78+
return value;
79+
}
80+
81+
export function canonicalizeKind(input, { field = 'kind' } = {}) {
82+
const value = asString(input, field).trim().toLowerCase();
83+
if (!ALLOWED_KINDS.has(value)) {
84+
throw new CmsValidationError(
85+
`${field} must be one of: ${Array.from(ALLOWED_KINDS).join(', ')}`,
86+
{ code: 'kind_invalid', field }
87+
);
88+
}
89+
return value;
90+
}
91+
92+
export function resolveContentIdentity({ slug, trailers = {} }) {
93+
const canonicalSlug = canonicalizeSlug(slug, { field: 'slug' });
94+
const candidate = trailers?.contentId ?? trailers?.contentid;
95+
96+
if (candidate == null || candidate === '') {
97+
return { slug: canonicalSlug, contentId: canonicalSlug };
98+
}
99+
100+
const canonicalContentId = canonicalizeSlug(candidate, { field: 'contentId' });
101+
if (canonicalContentId !== canonicalSlug) {
102+
throw new CmsValidationError(
103+
`contentId "${canonicalContentId}" must match canonical slug "${canonicalSlug}"`,
104+
{ code: 'content_id_mismatch', field: 'contentId' }
105+
);
106+
}
107+
108+
return { slug: canonicalSlug, contentId: canonicalContentId };
109+
}

0 commit comments

Comments
 (0)