Skip to content

Commit 64e0bbd

Browse files
os-zhuangclaude
andauthored
chore(lint): add org-identifier authoring guard for deprecated session.tenantId (#3280 follow-up) (#3289)
`organizationId` is the blessed developer-facing name for the caller's active org in hook/action bodies (#3280); `ctx.session.tenantId` is a deprecated alias. TSDoc `@deprecated` only nudges an author whose editor surfaces it — nothing stopped our own reference apps (examples/, apps/), which authors and AIs copy from, re-introducing `session.tenantId`. scripts/check-org-identifier.mjs is a hard-fail guard (authoring surfaces carry zero occurrences today) wired into the lint workflow. Deliberately narrow: - scans author-facing reference code only (examples/, apps/) — internal packages legitimately read `session.tenantId` (driver-layer alias, a #3280 non-goal); - excludes skills/ + content/docs/, which teach the deprecated form on purpose; - matches only the `session.tenantId` token, never `execCtx.tenantId` / `opts.tenantId` / `DriverOptions.tenantId` (the generic tenancy knob); - escape hatch: `os-allow-tenant-id` comment on the line. Proven red on a new occurrence, green when reverted, suppressed by the marker. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b39c65d commit 64e0bbd

3 files changed

Lines changed: 114 additions & 0 deletions

File tree

.github/workflows/lint.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ jobs:
7575
- name: Reserved-word ("role") docs ratchet
7676
run: pnpm check:role-word
7777

78+
# #3280 org-identifier guard: `organizationId` is the blessed developer-facing
79+
# name for the caller's active org in hook/action bodies; `session.tenantId`
80+
# is a deprecated alias. Keeps our own reference apps (examples/, apps/) —
81+
# which authors and AIs copy from — off the deprecated name. Hard-fail
82+
# (authoring surfaces carry zero occurrences today); skills/ and docs/ are
83+
# excluded since they teach the deprecated form, and driver-layer
84+
# `execCtx.tenantId` is never matched.
85+
- name: Org-identifier authoring guard
86+
run: pnpm check:org-identifier
87+
7888
# Authorization resolution must stay single-sourced (resolveAuthzContext,
7989
# @objectstack/core). Guards against a duplicate resolver copy drifting on a
8090
# security path (the REST-vs-dispatcher sys_user_role drift) and against an

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"check:nul-bytes": "node scripts/check-nul-bytes.mjs",
3030
"check:doc-authoring": "node scripts/check-doc-authoring.mjs",
3131
"check:role-word": "node scripts/check-role-word.mjs",
32+
"check:org-identifier": "node scripts/check-org-identifier.mjs",
3233
"check:authz-resolver": "node scripts/check-single-authz-resolver.mjs",
3334
"check:console-sha": "node scripts/check-console-sha.mjs",
3435
"check:release-notes": "node scripts/check-release-notes.mjs"

scripts/check-org-identifier.mjs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#!/usr/bin/env node
2+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
3+
//
4+
// check-org-identifier -- keeps author-facing reference code on the blessed
5+
// org name in hook/action bodies.
6+
//
7+
// #3280 made `organizationId` the blessed developer-facing name for the
8+
// caller's active org across the JS authoring surface: a hook or action body
9+
// reads `ctx.user.organizationId` / `ctx.session.organizationId`, matching the
10+
// `organization_id` column and `current_user.organizationId` in RLS. The old
11+
// `ctx.session.tenantId` is a DEPRECATED alias that still works but teaches the
12+
// wrong name -- and TSDoc `@deprecated` only nudges an author whose editor
13+
// surfaces it. Nothing stopped our own reference apps (examples/, apps/), which
14+
// authors and AIs copy from, from re-introducing `session.tenantId`.
15+
//
16+
// This is a hard-fail guard, not a ratchet: the authoring surfaces carry ZERO
17+
// occurrences today, so any match is a NEW one and fails. It is deliberately
18+
// NARROW:
19+
// • Scope is author-facing reference code only (examples/, apps/). Internal
20+
// framework packages legitimately read `session.tenantId` (the engine's own
21+
// `buildSession`, the record-change trigger) -- that is the driver-layer
22+
// alias, a non-goal of #3280, and must not be flagged.
23+
// • skills/ and content/docs/ are EXCLUDED: they deliberately show the
24+
// deprecated form when TEACHING the deprecation ("deprecated alias").
25+
// • The pattern matches only the `session.tenantId` token, never
26+
// `execCtx.tenantId` / `opts.tenantId` / `DriverOptions.tenantId`, which are
27+
// the generic driver-layer tenancy knob (explicitly out of scope).
28+
//
29+
// Escape hatch for a genuine driver-layer `session.tenantId` in an example:
30+
// add `os-allow-tenant-id` in a comment on the same line.
31+
//
32+
// node scripts/check-org-identifier.mjs
33+
//
34+
// Scope: tracked sources under examples/ and apps/ only (git ls-files).
35+
36+
import { execFileSync } from 'node:child_process';
37+
import { readFileSync } from 'node:fs';
38+
import { join } from 'node:path';
39+
40+
const ROOTS = ['examples', 'apps'];
41+
const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.cts', '.mts'];
42+
const EXCLUDED = /(^|\/)(node_modules|dist|build|\.next|\.turbo)\//;
43+
44+
// `ctx.session.tenantId`, `session?.tenantId`, `this.session . tenantId`, … —
45+
// the `session` receiver immediately before `.tenantId`. Anchored on the
46+
// `session` word so `execCtx.tenantId` / `opts.tenantId` never match.
47+
const PATTERN = /\bsession\s*\??\.\s*tenantId\b/;
48+
const ALLOW_MARKER = 'os-allow-tenant-id';
49+
50+
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], {
51+
encoding: 'utf8',
52+
}).trim();
53+
54+
// Newline-delimited on purpose (not `-z`): tracked paths under examples/ and
55+
// apps/ never contain a newline, and avoiding the NUL delimiter keeps this very
56+
// script free of any raw NUL byte (which would make it invisible to grep — the
57+
// exact #3127 failure mode this repo already guards with check:nul-bytes).
58+
const files = execFileSync('git', ['ls-files', '--', ...ROOTS], {
59+
cwd: root,
60+
encoding: 'utf8',
61+
maxBuffer: 64 * 1024 * 1024,
62+
})
63+
.split('\n')
64+
.filter(Boolean)
65+
.filter((f) => EXTENSIONS.some((ext) => f.endsWith(ext)))
66+
.filter((f) => !EXCLUDED.test(f));
67+
68+
const offenders = [];
69+
for (const file of files) {
70+
const text = readFileSync(join(root, file), 'utf8');
71+
const lines = text.split('\n');
72+
for (let i = 0; i < lines.length; i++) {
73+
const line = lines[i];
74+
if (!PATTERN.test(line)) continue;
75+
if (line.includes(ALLOW_MARKER)) continue;
76+
offenders.push({ file, line: i + 1, text: line.trim() });
77+
}
78+
}
79+
80+
if (offenders.length === 0) {
81+
console.log(
82+
`check-org-identifier: OK (${files.length} author-facing source file(s), no deprecated session.tenantId).`,
83+
);
84+
process.exit(0);
85+
}
86+
87+
const plural = offenders.length === 1 ? 'occurrence' : 'occurrences';
88+
console.error(
89+
`check-org-identifier: ${offenders.length} deprecated \`session.tenantId\` ${plural} in author-facing code\n`,
90+
);
91+
for (const o of offenders) {
92+
console.error(` • ${o.file}:${o.line} ${o.text}`);
93+
}
94+
console.error(`
95+
\`session.tenantId\` is a DEPRECATED alias (#3280). In a hook or action body read
96+
the caller's active org under the blessed name instead:
97+
98+
const org = ctx.user?.organizationId ?? ctx.session?.organizationId;
99+
100+
It carries the identical value and matches the \`organization_id\` column and
101+
\`current_user.organizationId\` in RLS. For a genuine driver-layer use, add an
102+
\`${ALLOW_MARKER}\` comment on the line.`);
103+
process.exit(1);

0 commit comments

Comments
 (0)