Imported from feasibleone/blong (
.github/skills/blong-core/SKILL.md). Install upstream withnpx skills add feasibleone/blong --skill blong-core. Copyright stays with the author.
blong-core — Resource graph, Party & Access realms
[CRITICAL_GUARDRAILS]
- Don't invent handlers like
resourceResourceAdd— CRUD is auto-provided; define schema + seeds only. - Use
type.uuid()for resource PKs —increment/ulidPKs don't get autocore_resourcecreation. - Seed the type alias before instances (
0--prefixed alias file sorts first). - Every
resourceTypeseed row needs aname— it's the merge/dedup key →resourceName. - Resource relationships often live in
core.triple, not FK columns — mixing breaks materialized-path queries. - Refresh
core.pathafter RBAC graph edits (CALL access_pathRefresh()) or effective queries go stale. - Reference entities by name in seeds/custom merges, never raw DB IDs.
Canonical framework rules + archetype:
.github/skills/_shared/conventions.md → [CRITICAL_GUARDRAILS], [ARCHETYPE: SCHEMA_TABLE].
Siblings: blong-schema (tables/seeds), blong-model (browser models).
Overview
Three realms in the Blong framework work together as the foundation layer that most business features are built on:
| Realm | What it is | What it gives you |
|---|---|---|
blong-core (@feasibleone/blong-core) |
The generic resource graph — a universal entity registry plus a relationship graph | core.resource, core.type, core.property, core.triple, core.translation, core.path tables. Any entity that needs to be referenced, related, tagged, translated, or reached through a relationship chain lives here. |
blong-party (@feasibleone/blong-party) |
Party management — people and organizations | person, organization, unit (as resources) + contact, address, identifier (as FK sub-tables). Org hierarchies and memberships are stored in the graph (belongsTo, isPartOf). |
blong-access (@feasibleone/blong-access) |
RBAC — authentication and authorization | user, credential, role, capability, action, plus policy, flow, access, session, audit. Roles → capabilities → actions resolved through the graph; authorization enforced at the gateway via JWT permissionMap. |
The relationship between them:
graph LR
subgraph core["blong-core — resource graph"]
R[core.resource] -->|typeId| T[core.type]
P[core.property] -->|resourceId| R
TR[core.triple] -->|subjectId/objectId| R
L[core.translation] -->|resourceId| R
PT[core.path] -->|originId/destinationId| R
end
subgraph party["blong-party"]
person/org/unit -->|PK = FK| R
end
subgraph access["blong-access"]
user/role/capability/action -->|PK = FK| R
end
The core idea: every named entity in party/access is also a core.resource row — its PK is a
FK to core.resource.resourceId and its readable name is resourceName; relationships are
core.triple edges, not join tables. One uniform query surface for RBAC, hierarchies, and relations.
When to use this skill
Reach for this skill when a task touches any of these:
- Extending
blong-core— adding a new core-level table/entity to the resource graph. - Building a new realm on top of core — any entity that should be a "resource" (referencable, relatable, translatable) should follow the shared-PK pattern.
- Working with parties — persons, organizations, org units, their contacts, addresses, identifiers; adding a new party type; querying who belongs to what.
- Working with RBAC / access — users, credentials, roles, capabilities, actions, policies, login flows; assigning a role to a user; adding a new action; protecting an endpoint.
- Querying the graph — "who can do X?", "what is Y related to?", "is A part of B?", reachability questions that go multiple hops deep.
- Wiring auth into a suite —
login.token.create+gateway.authorize+permissionMap.
Do not use this skill for generic schema/table work that doesn't involve resources, parties, or
RBAC — that's blong-schema. Do not use it to build new handler logic in general — that's
blong-handler / blong-orchestrator. This skill is about the domain model these three realms
provide and the extension points they expose.
The core data model (blong-core)
blong-core is intentionally just schema — it defines tables and seeds type aliases, and ships
no handlers of its own. CRUD is auto-provided by the runtime (see next section).
| Table | Purpose |
|---|---|
core.resource |
Universal entity registry. resourceId (UUID), resourceName (stable logical name, indexed — the lookup key), typeId (→ core.type). |
core.type |
Discriminator catalog. typeId (auto-increment), unique typeAlias (e.g. party.person, access.role). |
core.property |
Generic key/value attributes per resource: (resourceId, propertyName, propertyValue). Arbitrary extension without schema changes. |
core.triple |
The relationship graph. (subjectId, predicateName, objectId) — both endpoints FK to core.resource. |
core.translation |
i18n display names per resource: (resourceId, languageCode, translatedName). |
core.path |
Materialized reachability: (originId, destinationId, pathType, pathDepth). Precomputed "can X reach Y through predicate-chain P" — makes deep RBAC lookups fast (no recursive traversal at query time). |
Type aliases are seeded via meta/db/0-coreTypeMerge.yaml:
# core/blong-core/meta/db/0-coreTypeMerge.yaml
key: typeAlias
type:
- typeAlias: core.currency
- typeAlias: core.language
- typeAlias: core.country
- typeAlias: core.city
Core runtime behaviors (critical to understand)
Two framework behaviors in the knex adapter (blong-gogo) do most of the heavy lifting. They are
automatic — do not write handlers to replicate them.
-
addon a resource-backed table auto-creates thecore_resourcerow. When a PK column usestype.uuid()and its FK iscore.resource.resourceId, the runtime generates a UUID, looks up thecore.typerow whose alias is`${subject}.${object}`, inserts thecore_resourcerow (resourceName=`${subject}.${object}.${columnName}`), then inserts the entity row.(You never write this — the runtime looks up the
core.typealias${subject}.${object}and inserts thecore_resourcerow on your behalf.) -
mergewith aresourceTypeparam resolves/createscore_resourcerows byname. When you seed or merge rows and passresourceType, the runtime looks up (or creates) thecore_resourcerow for each entity'snameproperty and uses itsresourceIdas the entity PK.
Implication: your realm never calls a resourceResourceAdd-style handler — that doesn't exist.
You define the schema correctly (PK = type.uuid() + FK to core.resource.resourceId), register
the table and the type alias, and the framework keeps core_resource in sync for you.
Building a resource-based entity (the shared-PK pattern)
This is the canonical extension path, used identically by party, access, and any future realm.
1. Define the entity in meta/type/schema.ts
// myrealm/meta/type/schema.ts
import {schema} from '@feasibleone/blong';
export default schema(async ({lib: {type}}) => ({
item: type.Object(
{
itemId: type.uuid(), // ← PK, auto-generates a UUID
itemName: type.stringNotNull(),
description: type.stringNull(),
},
{
constraints: {
primaryKey: 'itemId',
foreign: {
itemId: 'core.resource.resourceId', // ← makes it a resource
},
},
},
),
}));
Use type.uuid() for the PK (stable identity across systems) and the FK constraint to
core.resource.resourceId. type.increment()/type.ulid() PKs do not get the auto
core_resource behavior unless you add it yourself.
2. Register the table in meta/db/db.ts
// myrealm/meta/db/db.ts
import {handler} from '@feasibleone/blong';
export default handler(() => ({
config: {
schema: {
dbTest: true,
tables: {
// Order > core's tables (core.* uses order 1) so core exists first.
'myrealm.item': 400,
},
},
},
}));
3. Seed the type alias (meta/db/0-myrealmTypeMerge.yaml)
The 0- prefix ensures the alias exists before anything references it (files process in
alphabetical order):
# myrealm/meta/db/0-myrealmTypeMerge.yaml
key: typeAlias
type:
- typeAlias: myrealm.item
4. Seed instances with resourceType + name
Every seed row needs a name — it is the merge key that maps to core_resource.resourceName and
makes the merge idempotent:
# myrealm/meta/db/myrealmItemMerge.yaml
resourceType: myrealm.item
key: itemId
item:
- name: Widget
description: A basic widget
- name: Gadget
description: An advanced gadget
Test seeds go in meta/dbTest/ (loaded only in dev/integration when schema.dbTest: true).
Then the auto-bound CRUD works: myrealm.item.add, myrealm.item.find, myrealm.item.merge,
etc., with core_resource maintained automatically. You typically need no handler files for
basic CRUD — only for custom logic (e.g. access's authorization merge).
Party realm (blong-party)
What it provides
| Table | PK | Notes |
|---|---|---|
party.person |
personId → core.resource |
firstName / middleName / lastName / birthDate / gender / maritalStatus / nationality / occupation |
party.organization |
organizationId → core.resource |
legalName / tradingName / registrationNumber / taxId / industry / website |
party.unit |
unitId → core.resource |
unitName / unitType (department, branch, division, team) |
party.contact |
partyContactId (increment) |
FK partyResourceId → core.resource; contactType + contactValue + isPrimary |
party.address |
partyAddressId (increment) |
FK partyResourceId; addressType / streetAddress / city / stateProvince / postalCode / countryId |
party.identifier |
partyIdentifierId (increment) |
FK partyResourceId; identifierType / value / issuingAuthority / issue & expiry dates |
Hierarchy lives in the graph, not in columns
Party has no organizationId/parentUnitId FK columns and no member join tables. All hierarchy
and membership relationships are core.triple edges:
| Predicate | Meaning |
|---|---|
belongsTo |
unit → organization (unit belongs to an org); person → unit (person is a member) |
isPartOf |
unit → parent unit (tree hierarchy — child under parent) |
This is deliberate: because access's RBAC traversal already understands belongsTo on
core_triple, a person's unit membership feeds straight into role/action resolution
(user → unit → role → capability → action).
Extending party
- New party type (e.g.
party.vendor): follow the shared-PK pattern above — add the entity with PK =type.uuid()+ FK tocore.resource.resourceId, register the table (order > 300), seed the alias in0-coreTypeMerge.yaml, and (optionally) seed instances withresourceType. - New sub-entity (like contact/address/identifier — details attached to a party): plain table
with an auto-increment PK and an FK column (
partyResourceId) tocore.resource.resourceId. These are not resources themselves — they hang off a party resource. - Browser models: party ships models (
partyPersonModel,partyOrganizationModel,partyUnitModelinmeta/model/) that drive the model system's Browse/New/Open pages. If you add a party entity, add a matching{subject}{Object}Modelfor the UI. See theblong-modelskill. - Validation wiring: models declaring
public: trueget validation schemas by default viasubject.validation— no per-model config needed. A suite can opt out withvalidations: false(all) or{model: false}(one), e.g. mocks until the DB schema is defined. These register validation schemas only; they do not generate handler implementations (party uses real DB tables, not mocks).
Using party
- CRUD:
party.person.add/find/get/edit/remove/merge(same for organization/unit). - Query membership/hierarchy: query
core.triplewithpredicateName = 'belongsTo' | 'isPartOf'(see Querying the graph below). - Contact/address/identifier: currently schema-only in the realm — no handler wiring/CRUD. If a feature needs them, extend the realm to expose them.
Access realm (blong-access)
What it provides
| Table | PK | Notes |
|---|---|---|
access.user |
userId → core.resource |
emailAddress, isActive |
access.credential |
credentialId (increment) |
FK userId; credentialType (password/clientSecret), secret hash + salt, credentialParamsJSON (function + params), isActive, expiresAt |
access.role |
roleId → core.resource |
roleBit (0–1023, unique), description |
access.capability |
capabilityId → core.resource |
groups actions into a "what" |
access.action |
actionId → core.resource |
description; name (in resourceName) is the semantic triple |
access.policy |
policyId → core.resource |
credential complexity/lifecycle rules + credentialParamsJSON (dictated credential-function params; password policy is seeded) |
access.flow |
flowId → core.resource |
MFA step definitions, e.g. ["password","totp"] (schema-only) |
access.access |
accessId → core.resource |
time/IP/geo rule config (schema-only) |
access.session |
sessionId (uid, standalone) |
active sessions (created on login, refreshed on renewal) |
access.audit |
auditId (ulid) |
append-only auth event log (schema-only) |
The RBAC model
Authorization is a chain stored in the graph:
graph LR
U[user] -->|hasRole| R[role]
U -->|belongsTo| UN[unit]
UN -->|hasRole| R
R -->|hasCapability| C[capability]
C -->|hasAction| A[action]
Two SQL views + one stored procedure materialize reachability into core.path:
access_effectiveRolePath—user → role(direct) anduser → unit → role(inherited viabelongsTo).access_effectiveActionPath—user → role → capability → action(depth 3) anduser → unit → role → capability → action(depth 4).access_pathRefresh— deletes and rebuildscore_pathforpathTypein (access.effectiveRole,access.effectiveAction).
Authorization queries read the materialized core_path (a single indexed lookup on originId +
pathType), never recursive traversal.
The auth flow (how login + authorization work)
login.token.create(fromblong-login) →access.credential.check.accessCredentialCheck(adapter/db) looks up the user bycore_resource.resourceName+typeAlias = 'access.user', checksisActive, verifies the secret using the credential's stored parameters —credentialParamsJSON(a*JSONcolumn) holds the function and its params, e.g.{"function":"hash","algorithm":"pbkdf2","iterations":100000,"keyLength":64,"digest":"sha512"}(falling back to theconfig.passworddefaults declared in the realm'sserver.tswhen not stored) — then reads effective role bits + action names fromcore_path.- Role bits are packed into a base64
permissionMapbitmask (roleBit 0–1023 → bit position). loginTokenCreatesigns a JWT carryingper: permissionMap(andsub= actorId), creates the DB session, and sets the restore cookie.- The gateway's
authorizehook (access.authorization.list) decodesperfrom the token, maps role bits → capabilities → actions, and returns allowed methodIds (lowercase, dots stripped — e.g.accesstestprivate). ApreHandlerhook compares the requested method's methodId against that list: missing → 403, no/invalid token → 401.
Wire it in your suite's index.ts:
config: {
default: {
srv: {},
gateway: {authorize: 'access.authorization.list'}, // ← turns RBAC on
},
...
}
The login response also returns permissions (the resolved action names) for client-side display.
Key handlers (in adapter/db)
| Handler | Wire method | Purpose |
|---|---|---|
accessCredentialCheck |
access.credential.check |
verify credentials, return userId + permissionMap + actions |
accessAuthorizationList |
access.authorization.list |
permissionMap → allowed action methodIds (TTL-cached) — used by the gateway authorize hook |
accessAuthorizationMerge |
access.authorization.merge |
idempotent upsert of users/roles/capabilities/actions + CALL access_pathRefresh() — also the target of the test seed YAML |
Extending access
Capability/action naming convention: use NON-dotted handler names by default (
invoiceInvoiceAdd,invoiceManage) in action/capability seeds and in thepermissions/permissionMapmatching (theaccessAuthorizationListhandler returns methodIds with dots stripped). Dotted forms (invoice.invoice.add) are explicit special cases and discouraged. Matching is dot- and case-insensitive (invoice.invoice.add,invoiceInvoiceAdd, andINVOICEINVOICEADDall resolve to the same methodId), so both styles grant the same permission. A dotted name is only required when the action refers to a non-handler dotted resource — e.g.subject.object.schema(a schema/type resource) or third-party dotted names likevision.compute. For handler-backed RPC methods always use the non-dotted handler name.
-
New action: seed a row with
resourceType: access.action+name(the NON-dotted handler name, e.g.invoiceInvoiceApprove) inmeta/db/(prod) ormeta/dbTest/(test). Add a gatewayvalidationwrapper if it should be a public RPC method. Protect it by granting a capability viahasAction. -
New capability: seed with
resourceType: access.capability+name; link it to its actions. -
New role: seed with
resourceType: access.role+name+ a freeroleBit(0–1023); link capabilities viahasCapability. After any graph change, runCALL access_pathRefresh()(theaccessAuthorizationMergehandler does this for you). -
New user: seed or call
access.authorization.mergewith{user: {name: ..., password: ..., roles: ...}}— it creates the credential and thehasRoleedges. The credential's hashing params resolve as policy →config.password→ built-in literals and are stored on the row ascredentialParamsJSONso verification always re-uses them. -
Roles/capabilities/actions in bulk: use
accessAuthorizationMergewith the YAML seed pattern — reference entities by name, never by raw ID:# meta/dbTest/accessAuthorizationMerge.yaml user: testUser: {password: testPassword, roles: Admin} role: Admin: testManagement # role → capability capability: testManagement: accessTestPrivate # capability → action -
Seed roles for production:
meta/db/accessRoleMerge.yamlseeds Admin(bit0)/Manager(bit1)/ CustomerService(bit2)/Customer(bit3) ascore.resource+access_rolerows. -
Credential-function parameters (hashing etc.): use the
adapter/db/password.tslibrary (hashPassword,verifyPassword,resolveCredentialParams,credentialPolicyParams). The active policy for a credential type (lookup bycredentialType+isActiveonaccess.policy) dictates the params via its owncredentialParamsJSON; thepasswordpolicy is seeded inmeta/db/accessAuthorizationMerge.yaml(credentialParams:block). Config defaults live in the realm'sserver.ts(config.default.db.password), reused in every suite that includes the realm. -
Google identity exchange (OIDC & OAuth):
access.identity.check(vialogin.token.exchange) supports both flows simultaneously, selected per call with theflowparameter (oidcdefault,oauth) —oidcuses OIDC Discovery to resolve the token/JWKS/userinfo endpoints and enriches the profile from UserInfo;oauthuses${baseUrl}/token+${baseUrl}/certsdirectly. Seeadapter/db/oidc.tsand the mock insim/google/mockServer.ts. -
New policy/flow (password rules, MFA steps): policies can now dictate credential params; flow/access tables exist as schema — wire handlers to consume them.
-
Sessions / refresh / audit: DB-backed sessions (
access.session.*), redeemable refresh tokens (login.token.refresh), the restore cookie (login.token.restore) and the access-check audit (access.audit.record) are all wired — see Sessions, refresh tokens & audit below. -
Test endpoints:
adapter/dbTest/accessTestPrivate(protected) andaccessTestPublic(auth: false) are reference endpoints proving the 401/403/200 gate — replace with real business actions.
Sessions, refresh tokens & audit
Access tokens are stateless JWTs verified at the gateway without a DB hit. Sessions are DB-backed and add revocation + inactivity + renewal + audit on top of that fast path.
Session lifecycle
- Created on every
login.token.create(password and client_credentials). The JWTsesclaim carries the realsessionId;tokenHash= SHA-256 of the current refresh token (rotated on every renewal);lastActivityAtanchors the inactivity timer;cookieHash= SHA-256 of the restore-cookie handle. - Renewal (
login.token.refresh): validates the DB session (not revoked / not expired / not inactive) and toucheslastActivityAt, re-resolves the CURRENT permission set, mints a fresh access token + a rotated refresh token, and rotatestokenHash. Reuse of an already-rotated refresh token revokes the session. - Close (
access.session.close/ logout):isRevoked=1,revokedAt=now, cookie cleared. Already-issued access tokens keep working until they expire — renewal is refused, so the client is effectively logged out within one access-token lifetime. - Inactivity / deletion: sessions idle longer than
login.expire.inactivity(default 30 m) are refused renewal;access.session.cleanuppurges stale/revoked/expired rows afterlogin.expire.deleteAfter(default 24 h). Cleanup is dialect-neutral knex (no stored procs).
Standard method for critical operations
Normal operations rely on the JWT fast path. Operations that must NOT run on a closed/inactive
session (e.g. DB writes) should verify it at the start of the handler — access.session.verify
throws an access.session.* (401) error when the session is not live:
const {userId} = await handler.accessSessionVerify({}, $meta); // sessionId = $meta.auth.sessionId; throws on invalid
access.session.verify checks exists → not revoked → not past expiresAt → not inactive, throwing
access.session.notFound / access.session.revoked / access.session.expired /
access.session.inactive — the failing reason is on error.params.reason. Pass touch: true to
reset the inactivity timer.
Login eligibility (who may hold a session)
A user may establish or renew a session only while they are still allowed to log in. Two gates
are enforced at the session-lifecycle operations — login.token.create / login.token.refresh /
login.token.restore:
- Per-user —
user.isActivemust betrue. Deactivating a user refuses new logins (already checked ataccess.credential.check) and now also refuses renewal/restore, so the disable takes effect within one access-token lifetime. - Per-role — the user's effective permission set must include the well-known
accessLoginaction (unconditional), granted the usual way:role → capability → action. The shared blong-access production seed already grantsAdmintheloginCapability(→accessLogin) capability, so a realm only needs to add it for any additional roles that may log in (e.g.capability: loginCapability: accessLogininmeta/db/accessAuthorizationMerge.yaml). Removing it from a role disables logins for that role.
Failed gates throw login.userInactive / login.loginNotAllowed (401) and are audited.
access.session.verify (the critical-operation gate) ALSO enforces login eligibility — after the
session-liveness checks it refuses a deactivated user (access.session.userInactive) or one whose
roles no longer grant accessLogin (access.session.loginNotAllowed), so a disabled user is out
immediately, not just at the next renewal.
blong-login works without blong-access (configurable methods)
blong-login does not hard-depend on blong-access: every access method it calls is configurable
via login.methods.* (wire name) and defaults to the blong-access handler. A lightweight suite
without blong-access can override a method with its own handler, or set one to false to disable
that functionality — e.g. sessionCreate/sessionVerify/… = false for stateless tokens,
auditRecord = false to skip auditing, permissionList = false to issue tokens without RBAC
permissions. A flow that needs a method which is disabled fails with a clear login.configurationError.
Audit
With gateway.audit: {handler: 'access.audit.record', exclude?: string[]} every access-control
decision (allow/deny) is recorded at the access check — the choke point all controlled
operations pass — with actor, session, method, outcome, HTTP status and IP. Access-table DML
(access.user.add, …) adds a sanitised detail (entity + id keys only, never credentials/hashes).
Login success/failure is recorded by the login flow. Audit is best-effort (never blocks). Opt out
per route with audit: false, or by methodId pattern in exclude (e.g. integration-test probes).
Restore cookie (skip login on reload)
Login sets an opaque restore cookie: HttpOnly + Secure + SameSite=Lax, Path-scoped to
/rpc/login/token/restore only, value = random handle (only its SHA-256 digest is stored),
rotated on use, TTL login.expire.cookie. On reload the UI calls login.token.restore to exchange
it for fresh tokens and skip the login screen. Logout clears the cookie + revokes the session.
Rationale and trade-offs: docs/blong/docs/concepts/sessions.md.
Querying the graph
These patterns work for any resource-based entity (core, party, access):
- Direct edges: query
core.triplebysubjectId/predicateName/objectId. - Effective/reachability: query
core.pathby(originId, pathType)— fast, precomputed. Used by access for effective roles/actions. - Type discrimination: join
core_resource → core_typeontypeAliasto filter by entity kind (e.g.access.user). - Display names:
core_resource.resourceName, orcore.translationfor localized labels. - Arbitrary attributes:
core.propertykey/value rows per resource.
Checklist — common tasks
| Task | What to do |
|---|---|
| New resource-based entity | schema (type.uuid() + FK to core.resource.resourceId) → db.ts table order > core's → 0-*TypeMerge.yaml alias → instance seeds with name |
| New party type | same pattern; order > 300; add a browser model |
| New party sub-entity | plain table, increment PK, FK partyResourceId → core.resource.resourceId |
| New action / capability / role / user | seed via resourceType YAML or accessAuthorizationMerge; refresh paths |
| Protect an endpoint | gateway validation wrapper + grant the action via a capability → role → user |
| Turn on RBAC in a suite | gateway: {authorize: 'access.authorization.list'} + include core/access realms as children |
| Store a hierarchy | core.triple edges with belongsTo / isPartOf (never FK columns) |
| Check "who can do X" | core.path on access.effectiveAction |
Pitfalls
removedoesn't delete thecore_resourcerow (orphaned resource — known gap). Handle cleanup explicitly if it matters.- Consider registering tables with order > core's.
core.*= order 1; party 300+, access 200+. A lower/equal order can break FK creation.
Where to look next
blong-schema— full schema/seed/procedure reference (constraints, orders, YAML merge patterns).blong-model— browser CRUD pages from model specs (party models).blong-handler/blong-orchestrator/blong-error— writing the handlers that consume or extend these realms.blong-rest/blong-validation— exposing access-protected endpoints as RPC/REST.- Reference implementations:
core/blong-access(RBAC + path materialization),core/blong-party(resource-based entities + models),core/blong-marine(model system usage).