backend-skeleton 1.0.0-beta.1

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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/bin/bskel.mjs +2384 -0
  4. package/contracts/completeness.mjs +176 -0
  5. package/contracts/emit.mjs +287 -0
  6. package/contracts/export.mjs +325 -0
  7. package/contracts/openapi.mjs +869 -0
  8. package/contracts/validate.mjs +147 -0
  9. package/handles/_engine.mjs +281 -0
  10. package/handles/codec.mjs +119 -0
  11. package/handles/conformance.mjs +74 -0
  12. package/handles/providers/java-spring/ast-bridge.mjs +59 -0
  13. package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
  14. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
  15. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
  16. package/handles/providers/java-spring/ast-helper/gradlew +248 -0
  17. package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
  18. package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
  19. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
  20. package/handles/providers/java-spring/emit.mjs +232 -0
  21. package/handles/providers/java-spring/patch-strategy.mjs +229 -0
  22. package/handles/providers/java-spring/plan.mjs +377 -0
  23. package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
  24. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
  25. package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
  26. package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
  27. package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
  28. package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
  29. package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
  30. package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
  31. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
  32. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
  33. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
  34. package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
  35. package/handles/providers/java-spring.mjs +21 -0
  36. package/handles/providers/python-fastapi/emit.mjs +171 -0
  37. package/handles/providers/python-fastapi/plan.mjs +186 -0
  38. package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
  39. package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
  40. package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
  41. package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
  42. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
  43. package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
  44. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
  45. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
  46. package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
  47. package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
  48. package/handles/providers/python-fastapi.mjs +22 -0
  49. package/handles/providers/typescript-express/emit.mjs +128 -0
  50. package/handles/providers/typescript-express/plan.mjs +234 -0
  51. package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
  52. package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
  53. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
  54. package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
  55. package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
  56. package/handles/providers/typescript-express.mjs +20 -0
  57. package/handles/registry.mjs +90 -0
  58. package/lib/cli.mjs +430 -0
  59. package/lib/doctor.mjs +200 -0
  60. package/lib/exit-codes.mjs +67 -0
  61. package/lib/featureid.mjs +55 -0
  62. package/lib/featurelifecycle.mjs +205 -0
  63. package/lib/fsutil.mjs +50 -0
  64. package/lib/gate-definitions.mjs +293 -0
  65. package/lib/gates.mjs +263 -0
  66. package/lib/handles-manifest.mjs +92 -0
  67. package/lib/lock.mjs +68 -0
  68. package/lib/patch-approvals.mjs +56 -0
  69. package/lib/paths.mjs +21 -0
  70. package/lib/repo.mjs +44 -0
  71. package/lib/schema-validate.mjs +56 -0
  72. package/lib/state.mjs +124 -0
  73. package/lib/template.mjs +35 -0
  74. package/lib/verify.mjs +206 -0
  75. package/lib/workflow.mjs +142 -0
  76. package/new/fastapi.mjs +165 -0
  77. package/new/index.mjs +62 -0
  78. package/new/params.mjs +233 -0
  79. package/new/spring.mjs +198 -0
  80. package/new/templates/fastapi/README.md +26 -0
  81. package/new/templates/fastapi/app/__init__.py +0 -0
  82. package/new/templates/fastapi/app/main.py +8 -0
  83. package/new/templates/fastapi/gitignore +6 -0
  84. package/new/templates/fastapi/pyproject.toml +14 -0
  85. package/package.json +50 -0
  86. package/scanners/adapters/_express-shared.mjs +238 -0
  87. package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
  88. package/scanners/adapters/generic-grep.mjs +128 -0
  89. package/scanners/adapters/java-spring.mjs +301 -0
  90. package/scanners/adapters/javascript-express.mjs +422 -0
  91. package/scanners/adapters/python-fastapi.mjs +348 -0
  92. package/scanners/adapters/typescript-express.mjs +299 -0
  93. package/scanners/capabilities.mjs +90 -0
  94. package/scanners/conformance.mjs +59 -0
  95. package/scanners/db/introspect.mjs +109 -0
  96. package/scanners/db/migrations.mjs +126 -0
  97. package/scanners/index.mjs +281 -0
  98. package/scanners/registry.mjs +130 -0
  99. package/scanners/render.mjs +136 -0
  100. package/scanners/text-util.mjs +8 -0
  101. package/schemas/adapter.schema.json +23 -0
  102. package/schemas/agent-envelope.schema.json +21 -0
  103. package/schemas/contract-resolution.schema.json +28 -0
  104. package/schemas/feature-contract.schema.json +78 -0
  105. package/schemas/feature-index.schema.json +25 -0
  106. package/schemas/feature.schema.json +17 -0
  107. package/schemas/gate-event.schema.json +19 -0
  108. package/schemas/handles-plan.schema.json +31 -0
  109. package/schemas/handles-provider.schema.json +26 -0
  110. package/schemas/patch-approvals.schema.json +28 -0
  111. package/schemas/scan-report.schema.json +102 -0
  112. package/schemas/stack-choice.schema.json +89 -0
  113. package/schemas/stack-record.schema.json +20 -0
  114. package/schemas/state.schema.json +43 -0
  115. package/scripts/preflight-base-ref.sh +226 -0
  116. package/stack/apply.mjs +159 -0
  117. package/stack/bootstrap/_lib.sh +73 -0
  118. package/stack/bootstrap/ngrok.sh +90 -0
  119. package/stack/catalog/ngrok.yml +63 -0
@@ -0,0 +1,35 @@
1
+ -- Generated by backend-skeleton (bskel handles emit) for feature {{FEATURE_ID}}.
2
+ -- NOT applied automatically -- review it and apply yourself (e.g. via `psql` or your own
3
+ -- Alembic setup, wrapped in `op.execute(open("migration.sql").read())` if you use one). See
4
+ -- D-config-patch / D-migration-scope in DECISIONS.md for why backend-skeleton never applies
5
+ -- schema changes on its own. Same table names/columns as the java-spring provider's own
6
+ -- migration.sql.tmpl, so both providers' generated schemas agree at the DDL level.
7
+
8
+ create table if not exists sbf_handle (
9
+ handle_uid uuid primary key,
10
+ kind text not null check (kind in ('r', 'f', 'o')),
11
+ resource_type text not null,
12
+ resource_uid uuid not null,
13
+ pointer text,
14
+ feature_uid uuid not null,
15
+ operation_id text,
16
+ contract_ref text not null,
17
+ created_at timestamptz not null default now(),
18
+ revoked_at timestamptz,
19
+ revoked_reason text,
20
+ unique (resource_type, resource_uid, pointer)
21
+ );
22
+
23
+ create index if not exists ix_sbf_handle_resource on sbf_handle (resource_type, resource_uid);
24
+
25
+ create table if not exists sbf_handle_snapshot (
26
+ snapshot_id bigserial primary key,
27
+ handle_uid uuid not null references sbf_handle (handle_uid),
28
+ envelope_dir text not null check (envelope_dir in ('request', 'response', 'error')),
29
+ operation_id text not null,
30
+ contract_hash text not null,
31
+ payload jsonb not null,
32
+ recorded_at timestamptz not null default now()
33
+ );
34
+
35
+ create index if not exists ix_sbf_handle_snapshot_handle_uid on sbf_handle_snapshot (handle_uid, recorded_at desc);
@@ -0,0 +1,155 @@
1
+ """Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
+
3
+ G4 follow-up (D-handles-providers): the opt-in AUTOMATIC half of the handle lifecycle -- apply
4
+ `@record_snapshot(...)` to an EXISTING service function (never generated onto one -- a human
5
+ decides which functions are worth handle-tracking) to have it automatically register the
6
+ resource-level handle and record the request/response/error envelope around every call.
7
+
8
+ Combines Java's separate `@RecordHandleSnapshot` annotation + `HandleAspect` interceptor into ONE
9
+ file/decorator, deliberately -- Python decorators natively ARE this ecosystem's method-
10
+ interception mechanism, so there is no separate "declare a marker" vs. "implement the
11
+ interceptor" split to preserve; splitting this into two files would be cargo-culting Java's file
12
+ count, not mirroring an invariant.
13
+
14
+ `resource_uid_param`/`session_param` are explicit parameter NAMES (not positional indices --
15
+ resolved via inspect.signature(fn).bind(...), so the SAME function can be called positionally or
16
+ by keyword by different callers without silently misattributing a snapshot to the wrong resource,
17
+ which a bare `args[index]` would do for any keyword call). `redact` is an explicit array of JSON
18
+ Pointers, never a guessed heuristic. A failure recording a snapshot is ALWAYS logged and
19
+ swallowed, never allowed to fail the real business call it wraps -- snapshot recording is
20
+ best-effort observability, not a new way for an unrelated call to start failing.
21
+
22
+ Requires nothing extra installed (unlike Java's spring-boot-starter-aop) -- Python decorators need
23
+ no framework support -- but still requires a human to apply it to their own code; codegen never
24
+ touches an existing business logic file.
25
+
26
+ Example:
27
+ @record_snapshot(resource_type="Organization", operation_id="update_organization",
28
+ resource_uid_param="organization_id", session_param="session",
29
+ redact=["/internal_note"])
30
+ def update_organization(session: Session, organization_id: uuid.UUID, request: UpdateOrganizationRequest) -> OrganizationResponse:
31
+ ...
32
+ """
33
+ import functools
34
+ import inspect
35
+ import logging
36
+ import uuid
37
+
38
+ from {{PKG}}.handles import handle_service
39
+ from {{PKG}}.handles.codec import derive_handle_uid
40
+ from {{PKG}}.handles.registry import resolver_for
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ def _redact(obj, pointer: str) -> None:
46
+ """Walks to `pointer`'s parent container and blanks the leaf value in place -- genuinely
47
+ simpler than Java's hand-rolled ObjectNode navigator, since Python containers are natively
48
+ mutable (no read-only-.at()-style limitation to work around). Same ~1/~0 unescaping.
49
+ """
50
+ if not pointer or not pointer.startswith("/"):
51
+ return
52
+ parts = [p.replace("~1", "/").replace("~0", "~") for p in pointer[1:].split("/")]
53
+ current = obj
54
+ for part in parts[:-1]:
55
+ if isinstance(current, dict) and part in current:
56
+ current = current[part]
57
+ elif isinstance(current, list) and part.lstrip("-").isdigit() and int(part) < len(current):
58
+ current = current[int(part)]
59
+ else:
60
+ return
61
+ leaf = parts[-1]
62
+ if isinstance(current, dict) and leaf in current:
63
+ current[leaf] = "***REDACTED***"
64
+ elif isinstance(current, list) and leaf.lstrip("-").isdigit() and int(leaf) < len(current):
65
+ current[int(leaf)] = "***REDACTED***"
66
+
67
+
68
+ def _request_payload(bound: inspect.BoundArguments, resource_uid_param: str, session_param: str):
69
+ """The remaining arguments once the resource-uid/session ones are excluded -- the sole
70
+ survivor UNWRAPPED (the common case: one DTO), matching Java's own HandleAspect#requestPayload
71
+ exactly, so a `redact` pointer written against the DTO's own fields (e.g. "/internal_note")
72
+ resolves correctly. A real bug this design initially had and a real functional test caught:
73
+ an earlier draft always wrapped in `{param_name: value}`, so "/internal_note" silently never
74
+ matched anything (the real shape was `{"request": {"internal_note": ...}}`) -- redaction
75
+ looked like it worked (no error) but never actually redacted anything in the common
76
+ single-DTO case. Falls back to a dict keyed by parameter name (not Java's positional list --
77
+ Python's bound arguments already carry names) only when more than one non-uid/session
78
+ parameter remains.
79
+ """
80
+ rest = {k: _to_jsonable(v) for k, v in bound.arguments.items() if k not in (resource_uid_param, session_param)}
81
+ if len(rest) == 1:
82
+ return next(iter(rest.values()))
83
+ return rest
84
+
85
+
86
+ def _to_jsonable(value):
87
+ """Best-effort plain-dict/list/scalar projection -- a Pydantic/SQLModel object's own
88
+ `.model_dump(mode="json")`, or the value as-is if it's already a plain JSON-shaped value
89
+ (matches the payload shapes this decorator actually receives: request DTOs, response models,
90
+ or a plain {"message": ...} error dict).
91
+ """
92
+ if hasattr(value, "model_dump"):
93
+ return value.model_dump(mode="json")
94
+ return value
95
+
96
+
97
+ def _safely(action, handle_uid, envelope_dir: str) -> None:
98
+ """Best-effort: any failure here is logged, never propagated -- see this module's own
99
+ docstring for why.
100
+ """
101
+ try:
102
+ action()
103
+ except Exception:
104
+ logger.warning("record_snapshot: could not record %s snapshot for handle %s -- the wrapped call proceeds unaffected", envelope_dir, handle_uid, exc_info=True)
105
+
106
+
107
+ def record_snapshot(*, resource_type: str, operation_id: str, resource_uid_param: str, session_param: str, redact: list[str] | None = None):
108
+ redact = redact or []
109
+
110
+ def decorator(fn):
111
+ signature = inspect.signature(fn)
112
+
113
+ @functools.wraps(fn)
114
+ def wrapper(*args, **kwargs):
115
+ bound = signature.bind(*args, **kwargs)
116
+ bound.apply_defaults()
117
+
118
+ resolver = resolver_for(resource_type)
119
+ if resolver is None:
120
+ logger.warning('record_snapshot: no resolver registered for resource_type "%s" on %s -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_type, fn.__qualname__)
121
+ return fn(*args, **kwargs)
122
+
123
+ resource_uid = bound.arguments.get(resource_uid_param)
124
+ if not isinstance(resource_uid, uuid.UUID):
125
+ logger.warning('record_snapshot: resource_uid_param "%s" on %s does not resolve to a UUID argument -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_uid_param, fn.__qualname__)
126
+ return fn(*args, **kwargs)
127
+
128
+ session = bound.arguments.get(session_param)
129
+ handle_uid = uuid.UUID(derive_handle_uid("r", resource_type, str(resource_uid), None))
130
+ contract_ref = resolver.contract_ref
131
+
132
+ def _register_and_record(envelope_dir, payload):
133
+ handle_service.register(session, "r", resource_type, resource_uid, None, resolver.feature_uid, operation_id, contract_ref)
134
+ handle_service.record_snapshot(session, handle_uid, envelope_dir, operation_id, contract_ref, payload)
135
+
136
+ def _record(envelope_dir, payload):
137
+ if isinstance(payload, (dict, list)):
138
+ for pointer in redact:
139
+ _redact(payload, pointer)
140
+ _safely(lambda: _register_and_record(envelope_dir, payload), handle_uid, envelope_dir)
141
+
142
+ request_payload = _request_payload(bound, resource_uid_param, session_param)
143
+ _record("request", request_payload)
144
+
145
+ try:
146
+ result = fn(*args, **kwargs)
147
+ except Exception as exc:
148
+ _record("error", {"message": str(exc)})
149
+ raise
150
+ _record("response", _to_jsonable(result))
151
+ return result
152
+
153
+ return wrapper
154
+
155
+ return decorator
@@ -0,0 +1,37 @@
1
+ """Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
+
3
+ In-process registry mapping a handle `type` string to its resolver instance -- deliberately NOT a
4
+ database table. NOT the same concept as the `HandleRegistry` DB table in `tables.py` (G4
5
+ follow-up, D-handles-providers) -- that table backs a real `GET /handles/{handle}/recover`;
6
+ this dict is pure in-process dispatch, no persistence at all. The names collide only because
7
+ Java's own naming does; treat them as unrelated.
8
+ """
9
+ from typing import Protocol, runtime_checkable
10
+ from uuid import UUID
11
+
12
+
13
+ @runtime_checkable
14
+ class ResourceResolver(Protocol):
15
+ type: str
16
+ # G4 follow-up: baked in at `bskel handles emit` time (regenerated every run, so a contract
17
+ # change is picked up automatically), never read from disk at runtime. handle_service.register
18
+ # stores contract_ref as a registry row's contract_ref; `recover`'s schema_drift check compares
19
+ # a snapshot's own recorded hash against the CURRENT value of this attribute, not a stale one.
20
+ contract_ref: str
21
+ feature_uid: UUID
22
+
23
+ def fetch(self, session, resource_uid): ...
24
+ def check_access(self, session, obj) -> None: ...
25
+ def patch_field(self, session, obj, pointer, value) -> None: ...
26
+ def to_public(self, obj): ...
27
+
28
+
29
+ _RESOLVERS: dict[str, "ResourceResolver"] = {}
30
+
31
+
32
+ def register(resolver: "ResourceResolver") -> None:
33
+ _RESOLVERS[resolver.type] = resolver
34
+
35
+
36
+ def resolver_for(type_: str) -> "ResourceResolver | None":
37
+ return _RESOLVERS.get(type_)
@@ -0,0 +1,59 @@
1
+ """Generated by backend-skeleton (bskel handles emit) for feature {{FEATURE_ID}}.
2
+ type = "{{RESOURCE_TYPE}}"
3
+
4
+ `fetch`/`to_public` are wired to real, existing code: `fetch` uses SQLModel's `session.get(...)`,
5
+ this stack's own canonical read path, and `to_public` projects through {{PUBLIC_MODEL}} -- required
6
+ so a generic handle-fetch route can never accidentally serialize a column the app does not
7
+ otherwise expose (a real example this provider was built against: a table model carrying a
8
+ password hash column with no `response_model=`-shaped protection of its own). See the fetch route
9
+ this was planned from: {{FETCH_ROUTE_FILE}}:{{FETCH_ROUTE_LINE}}.
10
+
11
+ `check_access` is DELIBERATELY a fail-closed stub, not auto-generated business logic. FastAPI has
12
+ no imperatively-readable global security context the way Spring's SecurityContextHolder is, and
13
+ this stack's own real authorization logic lives inside route function bodies (per-row checks),
14
+ which a static source scan cannot safely extract. Wire it to whatever this app's own current-
15
+ user/permission pattern is before relying on it -- until then, every request is denied.
16
+
17
+ `patch_field` is DELIBERATELY a stub for the same reason Java's ResourceResolverStub.java.tmpl's
18
+ patchField() is: this stack's own update conventions must be checked by a human before writing to
19
+ them (see D-resolver-scope in DECISIONS.md) -- do not write directly against the ORM, that
20
+ bypasses this app's existing validation and business rules.
21
+ """
22
+ import uuid
23
+
24
+ from fastapi import HTTPException
25
+
26
+ from {{MODEL_IMPORT}} import {{MODEL}}, {{PUBLIC_MODEL}}
27
+ from {{PKG}}.handles.registry import register
28
+
29
+ # G4 follow-up (D-handles-providers): baked in at `bskel handles emit` time, mirroring Java's
30
+ # ResourceResolverStub.java.tmpl exactly -- see registry.py's own ResourceResolver.contract_ref/
31
+ # feature_uid docstring for what these back.
32
+ CONTRACT_REF = "{{CONTRACT_REF}}"
33
+ FEATURE_UID = uuid.UUID("{{FEATURE_UID}}")
34
+
35
+
36
+ class {{RESOURCE_TYPE}}Resolver:
37
+ type = "{{RESOURCE_TYPE}}"
38
+ contract_ref = CONTRACT_REF
39
+ feature_uid = FEATURE_UID
40
+
41
+ def fetch(self, session, resource_uid):
42
+ return session.get({{MODEL}}, resource_uid)
43
+
44
+ def check_access(self, session, obj) -> None:
45
+ # TODO: wire this app's own current-user/permission check before relying on this resolver.
46
+ # Fails closed until then -- every request is denied, not silently permitted.
47
+ raise HTTPException(status_code=403, detail="access check not yet implemented for {{RESOURCE_TYPE}}")
48
+
49
+ def patch_field(self, session, obj, pointer, value) -> None:
50
+ # TODO: route through this app's real update path for {{MODEL}}, matching whichever
51
+ # partial-update convention its own schemas actually use. Do not write directly to `obj`'s
52
+ # attributes and commit -- that bypasses this app's existing validation/business rules.
53
+ raise HTTPException(status_code=501, detail=f"patch_field not yet implemented for {{RESOURCE_TYPE}}{pointer}")
54
+
55
+ def to_public(self, obj):
56
+ return {{PUBLIC_MODEL}}.model_validate(obj)
57
+
58
+
59
+ register({{RESOURCE_TYPE}}Resolver())
@@ -0,0 +1,13 @@
1
+ """Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
+
3
+ Auto-imports every sibling module in this package so each resolver's own `register(...)` call at
4
+ import time runs exactly once. Keeps this file itself feature-independent (its content never
5
+ depends on which resolvers exist), which is what lets `bskel handles emit` treat it as repo-owned
6
+ infra rather than needing to regenerate it per feature.
7
+ """
8
+ import importlib
9
+ import pkgutil
10
+
11
+ for _mod in pkgutil.iter_modules(__path__):
12
+ if not _mod.name.startswith("_"):
13
+ importlib.import_module(f"{__name__}.{_mod.name}")
@@ -0,0 +1,140 @@
1
+ """Generated by backend-skeleton.
2
+
3
+ D-handles (DECISIONS.md): exposed in production. Per-resolver `check_access()` is the entire
4
+ defense for this generic-object-accessor security surface -- see the docstring on each generated
5
+ resolver for why it is ALWAYS a fail-closed stub in this provider (FastAPI has no imperatively
6
+ readable global security context the way Spring's SecurityContextHolder is, and this app's own
7
+ real authorization logic lives inside route function bodies, not decorators -- a static source
8
+ scan cannot safely extract it).
9
+ """
10
+ import uuid
11
+ from datetime import datetime
12
+
13
+ from fastapi import APIRouter, HTTPException, Query
14
+ from sqlmodel import select
15
+
16
+ from {{PKG}}.handles.codec import decode_handle, derive_handle_uid, resolve_json_pointer, MISSING
17
+ from {{PKG}}.handles.registry import resolver_for
18
+ from {{PKG}}.handles.tables import HandleRegistry, HandleSnapshot
19
+ from {{SESSION_DEP_MODULE}} import {{SESSION_DEP_NAME}}
20
+
21
+ router = APIRouter(prefix="/handles", tags=["handles"])
22
+
23
+
24
+ @router.get("/{handle}")
25
+ def fetch_handle(handle: str, session: {{SESSION_DEP_NAME}}):
26
+ try:
27
+ decoded = decode_handle(handle)
28
+ except ValueError as exc:
29
+ raise HTTPException(status_code=400, detail=str(exc))
30
+
31
+ resolver = resolver_for(decoded.type)
32
+ if resolver is None:
33
+ raise HTTPException(status_code=404, detail=f'no resolver registered for handle type "{decoded.type}"')
34
+
35
+ obj = resolver.fetch(session, decoded.uuid)
36
+ if obj is None:
37
+ raise HTTPException(status_code=404, detail="resource not found")
38
+ resolver.check_access(session, obj)
39
+ public = resolver.to_public(obj)
40
+ if decoded.pointer is None:
41
+ return public
42
+
43
+ # G4 follow-up (D-handles-providers): walks the PUBLIC projection, not the raw `fetch()`
44
+ # row -- a deliberate, security-preserving departure from Java's own literal code shape, not
45
+ # a weakening. Java's fetch() safely returns a walkable object because it already delegates
46
+ # to the resource's real response DTO; this provider's own fetch() returns the raw ORM row by
47
+ # original G4 design (to_public() is a SEPARATE, required projection specifically because a
48
+ # table can carry columns -- e.g. a password hash -- with no protection besides each route's
49
+ # own response_model=). Walking the raw row here would silently reopen exactly the column-leak
50
+ # vector to_public() exists to close (a crafted field handle with pointer "/hashed_password").
51
+ root = public.model_dump(mode="json") if hasattr(public, "model_dump") else public
52
+ target = resolve_json_pointer(root, decoded.pointer)
53
+ if target is MISSING:
54
+ raise HTTPException(status_code=404, detail=f'pointer "{decoded.pointer}" does not resolve on {decoded.type}')
55
+ return target
56
+
57
+
58
+ @router.patch("/{handle}", status_code=204)
59
+ def patch_handle(handle: str, session: {{SESSION_DEP_NAME}}, value: dict):
60
+ try:
61
+ decoded = decode_handle(handle)
62
+ except ValueError as exc:
63
+ raise HTTPException(status_code=400, detail=str(exc))
64
+
65
+ # D-security-10 parity: checks kind explicitly, not just pointer-presence -- mirrors
66
+ # HandleController.java.tmpl's identical check.
67
+ if decoded.kind != "f" or decoded.pointer is None:
68
+ raise HTTPException(status_code=400, detail="cannot PATCH a resource-level handle (kind=r) -- only field handles (kind=f) support PATCH")
69
+
70
+ resolver = resolver_for(decoded.type)
71
+ if resolver is None:
72
+ raise HTTPException(status_code=404, detail=f'no resolver registered for handle type "{decoded.type}"')
73
+
74
+ obj = resolver.fetch(session, decoded.uuid)
75
+ if obj is None:
76
+ raise HTTPException(status_code=404, detail="resource not found")
77
+ resolver.check_access(session, obj)
78
+ resolver.patch_field(session, obj, decoded.pointer, value)
79
+
80
+
81
+ # G4 follow-up (D-handles-providers): mirrors HandleController.recover() including its full
82
+ # D-security-9 cross-check -- never weakened. handle_uid alone does not prove WHAT is being
83
+ # recovered (kind="r"'s derivation returns the resource UUID verbatim, no type binding baked into
84
+ # the hash), so an attacker who controls `type` in the token (as long as it names a real,
85
+ # registered resolver whose check_access() they can pass) could otherwise request the snapshot
86
+ # history of a DIFFERENT, more sensitive resource type sharing the same UUID. Fix: cross-check the
87
+ # decoded type/kind/pointer against the registry row this handle_uid was actually registered
88
+ # under, and 404 on ANY disagreement -- without saying which field mismatched, so the error can't
89
+ # be used to probe which part was wrong. A revoked handle is rejected the same way.
90
+ #
91
+ # Ecosystem-honest gap, not silently resolved: Java's role check is resource-existence-independent
92
+ # (recovering history of a since-deleted resource is legitimate), so it never calls fetch() at
93
+ # all before the registry check. This provider's own check_access(session, obj) is deliberately
94
+ # ROW-level (every shipped resolver's check_access already denies unconditionally regardless of
95
+ # obj, so calling fetch() first and passing obj=None through for a deleted resource is safe
96
+ # today) -- but a human wiring a real check_access() must handle obj=None explicitly, or recover()
97
+ # can never recall history for a resource that no longer exists.
98
+ @router.get("/{handle}/recover")
99
+ def recover_handle(handle: str, session: {{SESSION_DEP_NAME}}, at: datetime | None = Query(default=None)):
100
+ try:
101
+ decoded = decode_handle(handle)
102
+ except ValueError as exc:
103
+ raise HTTPException(status_code=400, detail=str(exc))
104
+
105
+ resolver = resolver_for(decoded.type)
106
+ if resolver is None:
107
+ raise HTTPException(status_code=404, detail=f'no resolver registered for handle type "{decoded.type}"')
108
+
109
+ obj = resolver.fetch(session, decoded.uuid)
110
+ resolver.check_access(session, obj)
111
+
112
+ handle_uid = uuid.UUID(derive_handle_uid(decoded.kind, decoded.type, decoded.uuid, decoded.pointer))
113
+ registry = session.get(HandleRegistry, handle_uid)
114
+ if (
115
+ registry is None
116
+ or registry.resource_type != decoded.type
117
+ or registry.kind != decoded.kind
118
+ or registry.pointer != decoded.pointer
119
+ or registry.is_revoked
120
+ ):
121
+ raise HTTPException(status_code=404, detail="no snapshot recorded for this handle")
122
+
123
+ query = select(HandleSnapshot).where(HandleSnapshot.handle_uid == handle_uid)
124
+ if at is not None:
125
+ query = query.where(HandleSnapshot.recorded_at <= at)
126
+ snapshot = session.exec(query.order_by(HandleSnapshot.recorded_at.desc())).first()
127
+ if snapshot is None:
128
+ detail = "no snapshot recorded for this handle" + (f" at or before {at}" if at is not None else "")
129
+ raise HTTPException(status_code=404, detail=detail)
130
+
131
+ return {
132
+ "handle": handle,
133
+ "recorded_at": snapshot.recorded_at,
134
+ "operation_id": snapshot.operation_id,
135
+ # snapshot.payload is already a native dict/list/scalar (a real JSONB column) -- no
136
+ # manual re-parse step exists here for the double-encoding bug Java once had to be
137
+ # possible in the first place. See tables.py's own docstring.
138
+ "payload": snapshot.payload,
139
+ "schema_drift": registry.contract_ref != snapshot.contract_hash,
140
+ }
@@ -0,0 +1,66 @@
1
+ """Generated by backend-skeleton. Do not hand-edit -- change the source template and regenerate.
2
+
3
+ G4 follow-up (D-handles-providers): mirrors Java's HandleRegistry.java.tmpl/HandleSnapshot.java.tmpl
4
+ (sbf_handle/sbf_handle_snapshot) -- same table names, columns, and unique(resource_type,
5
+ resource_uid, pointer) constraint, so both providers' generated schemas agree byte-for-byte at the
6
+ DDL level (see the emitted migration.sql). NOT the same concept as this package's own registry.py
7
+ -- that file is an in-process type->resolver dispatch dict; HandleRegistry here is a real database
8
+ table. The names collide only because Java's own naming does; treat them as unrelated.
9
+
10
+ `HandleSnapshot.payload` is a native `JSONB` column holding a plain dict/list -- SQLAlchemy
11
+ (de)serializes at the DB boundary automatically. There is no manual json.dumps/json.loads step
12
+ anywhere in this provider for `payload` to round-trip through, unlike Java's own `payload` column
13
+ (a manually (de)serialized String) -- the exact bug class that once caused Java's recover() to
14
+ double-JSON-encode a caller's payload (forgetting to re-parse a raw string before re-embedding it)
15
+ is structurally impossible here, not merely avoided by a matching fix.
16
+ """
17
+ import uuid
18
+ from datetime import datetime, timezone
19
+
20
+ from sqlalchemy import Column, UniqueConstraint
21
+ from sqlalchemy.dialects.postgresql import JSONB
22
+ from sqlmodel import Field, SQLModel
23
+
24
+
25
+ def _utcnow() -> datetime:
26
+ return datetime.now(timezone.utc)
27
+
28
+
29
+ class HandleRegistry(SQLModel, table=True):
30
+ __tablename__ = "sbf_handle"
31
+ __table_args__ = (UniqueConstraint("resource_type", "resource_uid", "pointer"),)
32
+
33
+ handle_uid: uuid.UUID = Field(primary_key=True)
34
+ # "r" (resource), "f" (field), or "o" (operation instance -- reserved, unused).
35
+ kind: str
36
+ resource_type: str
37
+ resource_uid: uuid.UUID
38
+ # RFC 6901 JSON Pointer, None for kind="r".
39
+ pointer: str | None = None
40
+ feature_uid: uuid.UUID
41
+ operation_id: str | None = None
42
+ contract_ref: str
43
+ created_at: datetime = Field(default_factory=_utcnow)
44
+ revoked_at: datetime | None = None
45
+ revoked_reason: str | None = None
46
+
47
+ @property
48
+ def is_revoked(self) -> bool:
49
+ return self.revoked_at is not None
50
+
51
+
52
+ class HandleSnapshot(SQLModel, table=True):
53
+ __tablename__ = "sbf_handle_snapshot"
54
+
55
+ snapshot_id: int | None = Field(default=None, primary_key=True)
56
+ handle_uid: uuid.UUID = Field(foreign_key="sbf_handle.handle_uid")
57
+ # "request", "response", or "error" -- what kind of payload this envelope holds.
58
+ envelope_dir: str
59
+ operation_id: str
60
+ # Hash of the feature contract this snapshot was recorded against. `recover` compares this to
61
+ # the CURRENT contract's hash -- a mismatch means the contract has since changed shape, and
62
+ # `recover` must say so explicitly (a schema_drift marker) rather than silently returning a
63
+ # payload the current contract no longer describes.
64
+ contract_hash: str
65
+ payload: dict | list | str | int | float | bool | None = Field(sa_column=Column(JSONB, nullable=False))
66
+ recorded_at: datetime = Field(default_factory=_utcnow)
@@ -0,0 +1,22 @@
1
+ import { plan } from './python-fastapi/plan.mjs';
2
+ import { emitPythonFastApi } from './python-fastapi/emit.mjs';
3
+
4
+ // D-handles-providers (G4). Zero-registration descriptor loaded by handles/registry.mjs -- see
5
+ // schemas/handles-provider.schema.json for the contract this object's JSON-shaped fields must
6
+ // match (plan/emit are functions, checked separately). The real second codegen provider G1's own
7
+ // D-adapter-registry EXIT held out for before the java-spring extraction (handles/providers/
8
+ // java-spring.mjs) was worth doing at all.
9
+ export const provider = {
10
+ contract: 'sbf.handles-provider/1',
11
+ id: 'python-fastapi',
12
+ title: 'Python / FastAPI / SQLModel',
13
+ requiresCapabilities: ['resource.fetch'],
14
+ // G4 follow-up (D-handles-providers): this provider now generates a real recover()
15
+ // lifecycle + sbf_handle/sbf_handle_snapshot migration, mirroring java-spring's own O4 work --
16
+ // the EXCLUDED reasoning that used to justify an empty outputs.spec here is stale.
17
+ outputs: { spec: ['handles/migration.sql'] },
18
+ plan,
19
+ emit(args) {
20
+ return emitPythonFastApi(args);
21
+ },
22
+ };
@@ -0,0 +1,128 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { emitUnits } from '../../_engine.mjs';
5
+
6
+ const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
7
+ const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
8
+ const RESOLVER_TEMPLATE = path.join(TEMPLATES_DIR, 'resolver.ts.tmpl');
9
+ const RESOLVERS_INDEX_TEMPLATE = path.join(TEMPLATES_DIR, 'resolvers_index.ts.tmpl');
10
+
11
+ function render(templatePath, vars) {
12
+ let content = fs.readFileSync(templatePath, 'utf8');
13
+ for (const [key, value] of Object.entries(vars)) {
14
+ content = content.replaceAll(`{{${key}}}`, String(value));
15
+ }
16
+ return content;
17
+ }
18
+
19
+ // PascalCase -> camelCase filename, good enough for the class names this scanner actually
20
+ // extracts (ASCII identifiers only, same assumption every other provider's own naming makes).
21
+ function camelCase(s) {
22
+ return s.length > 0 ? s[0].toLowerCase() + s.slice(1) : s;
23
+ }
24
+
25
+ // Relative TS import specifier from `fromFile`'s own directory to `toFile`, extension-stripped,
26
+ // forward-slash-joined (Node import specifiers are never OS-path-separated), always explicitly
27
+ // relative (`./x`, not bare `x`) so generated code never depends on a target's own tsconfig
28
+ // `baseUrl` being set a particular way.
29
+ function relativeImportPath(fromFile, toFile) {
30
+ const fromDir = path.dirname(fromFile);
31
+ let rel = path.relative(fromDir, toFile).replace(/\.ts$/, '');
32
+ rel = rel.split(path.sep).join('/');
33
+ if (!rel.startsWith('.')) rel = `./${rel}`;
34
+ return rel;
35
+ }
36
+
37
+ // G5 (D-typescript-express-provider): mirrors python-fastapi/emit.mjs's own 1st-slice shape
38
+ // exactly (as it existed at 627c214, before that provider's own separate recover()/snapshot
39
+ // follow-up) -- no migration, no recover(), see the EXCLUDED-equivalent reasoning in
40
+ // D-typescript-express-provider. Unlike Python, router.ts is infra emitted UNCONDITIONALLY (no
41
+ // SessionDep-shaped precondition exists for it -- TypeORM's DataSource is imported directly by
42
+ // each resolver, never injected per-request the way FastAPI's Depends()/SQLAlchemy Session is).
43
+ export function emitTypeScriptExpress({ repoRoot, featureId, plan, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false }) {
44
+ const handlesDir = path.join(plan.srcRoot, 'handles');
45
+ const resolversDir = path.join(handlesDir, 'resolvers');
46
+ const resolversIndexPath = path.join(resolversDir, 'resolvers_index.ts');
47
+
48
+ const infraUnits = [
49
+ { id: 'codec.ts.tmpl', templatePath: path.join(TEMPLATES_DIR, 'codec.ts.tmpl'), targetAbs: path.join(handlesDir, 'codec.ts'), rendered: render(path.join(TEMPLATES_DIR, 'codec.ts.tmpl'), {}) },
50
+ { id: 'registry.ts.tmpl', templatePath: path.join(TEMPLATES_DIR, 'registry.ts.tmpl'), targetAbs: path.join(handlesDir, 'registry.ts'), rendered: render(path.join(TEMPLATES_DIR, 'registry.ts.tmpl'), {}) },
51
+ { id: 'router.ts.tmpl', templatePath: path.join(TEMPLATES_DIR, 'router.ts.tmpl'), targetAbs: path.join(handlesDir, 'router.ts'), rendered: render(path.join(TEMPLATES_DIR, 'router.ts.tmpl'), {}) },
52
+ ];
53
+
54
+ const resolverUnits = plan.resources
55
+ .filter((r) => r.willGenerateResolver)
56
+ .map((resource) => {
57
+ const targetAbs = path.join(resolversDir, `${camelCase(resource.type)}.ts`);
58
+ const vars = {
59
+ FEATURE_ID: featureId,
60
+ RESOURCE_TYPE: resource.type,
61
+ MODEL: resource.type,
62
+ MODEL_IMPORT_PATH: relativeImportPath(targetAbs, path.join(plan.srcRoot, `${resource.modelImport}.ts`)),
63
+ DATA_SOURCE_NAME: resource.dataSource.name,
64
+ DATA_SOURCE_IMPORT_PATH: relativeImportPath(targetAbs, resource.dataSource.file),
65
+ ID_FIELD: resource.idField,
66
+ SELECT_PROJECTION: resource.selectFields.map((f) => `${f}: row.${f}`).join(', '),
67
+ FETCH_ROUTE_FILE: resource.fetchRoute ? path.relative(repoRoot, resource.fetchRoute.file) : '(unknown)',
68
+ FETCH_ROUTE_LINE: resource.fetchRoute ? resource.fetchRoute.line : '',
69
+ };
70
+ return {
71
+ id: 'resolver.ts.tmpl',
72
+ resourceType: resource.type,
73
+ module: plan.module,
74
+ templatePath: RESOLVER_TEMPLATE,
75
+ targetAbs,
76
+ rendered: render(RESOLVER_TEMPLATE, vars),
77
+ // FEATURE_ID is the only per-feature substitution (mirrors java-spring's/python-fastapi's
78
+ // own resolver templates -- no other var here changes between features for the SAME
79
+ // resource), so recovering the pristine render under a different owner is exactly the
80
+ // same render with FEATURE_ID swapped.
81
+ pristineRenderFor: (ownerId) => render(RESOLVER_TEMPLATE, { ...vars, FEATURE_ID: ownerId }),
82
+ };
83
+ });
84
+
85
+ const orphanScan = (!resourceFilter && plan.module) ? {
86
+ dir: resolversDir,
87
+ module: plan.module,
88
+ matchesFile: (file) => file.endsWith('.ts') && file !== 'resolvers_index.ts',
89
+ // Filename can't reliably recover the exact class-name casing -- read the `type: 'X'` field
90
+ // the resolver template itself carries instead of guessing from the filename, same content-
91
+ // read approach python-fastapi's own orphan scan already uses.
92
+ resourceTypeOf: (_file, content) => {
93
+ const m = content.match(/^\s*type:\s*'([^']+)'/m);
94
+ return m ? m[1] : null;
95
+ },
96
+ } : null;
97
+
98
+ const result = emitUnits({ repoRoot, featureId, provider: 'typescript-express', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff });
99
+
100
+ // The resolvers barrel's own import list is regenerated from the resolvers directory's REAL
101
+ // current contents (not just this run's own resolverUnits) -- an orphaned resolver from a
102
+ // different feature/module (O2's "never delete, only report" policy leaves it on disk) still
103
+ // needs its own `register(...)` call imported, or that resource type silently stops being
104
+ // servable. Unconditional, like migration.sql is for java-spring -- never manifest-tracked.
105
+ if (!dryRun) {
106
+ fs.mkdirSync(resolversDir, { recursive: true });
107
+ }
108
+ const currentResolverFiles = fs.existsSync(resolversDir)
109
+ ? fs.readdirSync(resolversDir).filter((f) => f.endsWith('.ts') && f !== 'resolvers_index.ts').sort()
110
+ : [];
111
+ const imports = currentResolverFiles.map((f) => `import './${f.replace(/\.ts$/, '')}';`).join('\n');
112
+ const resolversIndexContent = render(RESOLVERS_INDEX_TEMPLATE, { IMPORTS: imports });
113
+ const resolversIndexRelPath = path.relative(repoRoot, resolversIndexPath);
114
+ const resolversIndexDiskContent = fs.existsSync(resolversIndexPath) ? fs.readFileSync(resolversIndexPath, 'utf8') : null;
115
+ const resolversIndexAction = resolversIndexDiskContent === null ? 'create' : (resolversIndexDiskContent === resolversIndexContent ? 'unchanged' : 'update');
116
+ if (!dryRun && resolversIndexAction !== 'unchanged') {
117
+ fs.writeFileSync(resolversIndexPath, resolversIndexContent);
118
+ result.written.push(resolversIndexRelPath);
119
+ }
120
+ result.actions.push({ path: resolversIndexRelPath, kind: 'spec', action: resolversIndexAction });
121
+
122
+ return {
123
+ ...result,
124
+ postEmitNotes: [
125
+ `NOT done automatically: wiring the generated router into your app -- add "import { router as handlesRouter } from './handles/router';" and mount it via your app's own router-composition file (e.g. app.use(handlesRouter)) by hand.`,
126
+ ],
127
+ };
128
+ }