Skip to content
Back to skills

Server Marketing Consent

ASecurity

How to use @owlmeans/server-marketing-consent — the database-agnostic MarketingConsentService (status/save/terms/purge/isGranted/observe over two resources resolved by alias) plus the guarded status/save/terms entrypoint handlers for @owlmeans/marketing-consent's protocol tree. Auto-invoked when registering the marketing-consent service, writing a Mongo/Postgres extension that stores its two records, saving or reading a person's consent decisions server-side, or gating a send/share on a consent.

  • 3 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 29, 2026
devopsrustgotestingapidatabasefrontendbackend

Works with

  • api

Security analysis

A100/100

Scanned October 6, 2026

npx -y skills add owlmeans/common --skill server-marketing-consent --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Server Marketing Consent?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Server Marketing Consent
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/owlmeans-server-marketing-consent-common/badge)](https://www.skillsdirectory.com/skills/owlmeans-server-marketing-consent-common)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: server-marketing-consent
description: How to use @owlmeans/server-marketing-consent — the database-agnostic MarketingConsentService (status/save/terms/purge/isGranted/observe over two resources resolved by alias) plus the guarded status/save/terms entrypoint handlers for @owlmeans/marketing-consent's protocol tree. Auto-invoked when registering the marketing-consent service, writing a Mongo/Postgres extension that stores its two records, saving or reading a person's consent decisions server-side, or gating a send/share on a consent.
user-invocable: false
---
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->

# @owlmeans/server-marketing-consent

**Layer:** Server
**Install:** `"@owlmeans/server-marketing-consent": "^0.1.18-rc.16"` in `dependencies`
**Contracts:** `@owlmeans/marketing-consent` — the catalogue, `marketingConsentHelper.consentStatus`, the protocol tree, the error family

## Key Exports

| Export | Description |
|--------|-------------|
| `RES_MARKETING_CONSENT_STATE` (`marketing-consent-state`) · `RES_MARKETING_CONSENT_LOG` (`marketing-consent-log`) | Resource aliases — never rename, a later Mongo/Postgres extension's generated resource file names derive from these exact strings |
| `MarketingConsentStateRecord` · `MarketingConsentLogRecord` · `MarketingConsentStateSchema` · `MarketingConsentLogSchema` | The two record shapes and their AJV `JSONSchemaType` schemas — shared with the `@owlmeans/marketing-consent-mongo` / `@owlmeans/marketing-consent-postgres` extensions, which import them rather than redeclaring |
| `marketingConsentSubjectHelper.subjectOf(req)` · `.subjectKey(subject)` · `MarketingConsentSubject` | Who a decision is recorded for, and the `subject` key its one state record is addressed by |
| `makeMarketingConsentService(opts?)` · `appendMarketingConsentService(ctx, opts?)` | Build/register the `MarketingConsentService` |
| `MarketingConsentService` | `definitions`, `status`, `save`, `recordTerms`, `isGranted`, `purge`, `observe` |
| `marketingConsentStatus` · `saveMarketingConsent` · `recordTermsAcceptance` | Handler makers for `makeMarketingConsentProtocols().status/save/terms` |
| `serveMarketingConsentEntrypoints(protocols, opts?)` | Binds `status`/`save`/`terms` and asks for nothing else (`MarketingConsentServedProtocols`) — `base` and `screen` carry no server handler, so an api tree without the frontend `screen` passes as it is. A `base` declared with no `parent` must still be bound by the app (`bind(tree.base)`), or boot fails with `Entrypoint marketing-consent not found` |

## Wiring

```ts
import { appendMarketingConsentService, serveMarketingConsentEntrypoints } from '@owlmeans/server-marketing-consent'
import { makeMarketingConsentProtocols } from '@owlmeans/marketing-consent'

// shared package
export const marketingConsentProtocols = makeMarketingConsentProtocols({ parent: appProtocols.account.base })

// backend context — after the Mongo/Postgres extension has registered the two resources
appendMarketingConsentService(context, { config: appMarketingConsentConfig })

// API process
context.registerEntrypoints(serveMarketingConsentEntrypoints(marketingConsentProtocols))
```

`appendMarketingConsentService` is the same "an app that cares provides its own; this is the
working default" shape as `AUTH_CACHE`/`appendOAuthServer`: it registers nothing but the
`MarketingConsentService` itself (LazyService, `createLazyService`) and is a no-op if the alias is
already registered. The TWO RESOURCES it reads and writes (`RES_MARKETING_CONSENT_STATE`,
`RES_MARKETING_CONSENT_LOG`) are resolved BY ALIAS from the context lazily, inside each method call
— this package never imports `mongo-resource`/`postgres-resource` and never constructs them. A
Mongo or Postgres extension package registers the concrete resources at those two aliases
separately, and `appendMarketingConsentService` must run after that registration (resource lookup
is lazy per call, so registration order relative to `init()` does not matter — only "resource
exists under this alias before the first `status`/`save`/`terms` call" does).

## Why `decisions` is an ARRAY, never an object keyed by consent key

This is the single most important gotcha in this package. `MarketingConsentStateRecord.decisions`
holds `MarketingConsentDecision[]`, one entry per key, folded down to the latest per key on read
(`marketingConsentHelper.consentStatus`). It is never stored as `{ [key]: MarketingConsentDecision }`, because every
standard consent key is DOTTED (`marketing.email`, `data.profiling`) and a dotted key inside
an object field is read as a PATH by both Mongo's dot-notation queries (`{'decisions.marketing.email':
...}` reaches into a nested `email` field under a nested `marketing` field, not a literal key) and
Postgres jsonb path operators (`->` chains on segments). An object-keyed shape works in a quick
manual test with one key and breaks the moment a second dotted key is added — the two fields
silently merge into one nested tree instead of staying two siblings. Keep it an array in every
storage backend; fold by key only in memory, in `.consentStatus` or in a resource's own read path.

## `marketingConsentSubjectHelper.subjectOf` never uses the slug

```ts
const subjectOf = (req: AbstractRequest): MarketingConsentSubject => {
  if (req.auth?.userId == null || req.auth.userId === '') throw new AuthForbidden(...)
  return { userId: req.auth.userId, profileId: req.auth.profileId, entityId: req.entity?.id }
}
```

`entityId` reads `req.entity?.id` ONLY — never `makeEntityScope(req).requireEntityKey()`, never a slug off the token.
A deployment with no organization concept at all (a generated target app serving its own end users,
say) registers no entity resolver, so `req.entity` stays `undefined` and `entityId` is simply
absent — a valid, expected shape this package must never throw on. `marketingConsentSubjectHelper.subjectKey(subject)` —
`` `${entityId ?? ''}|${userId}|${profileId ?? ''}` `` — is the state record's `subject` field, and
every read and write addresses the record by it (`load({ subject })`, `purge({ subject })`), never
by `id`. The record's `id` is whatever the backend mints on `create()`: Mongo and Postgres refuse a
caller-supplied id outright (`RecordExists('id-present')`, whether or not such a row exists), so a
natural key can only ever live in a field. Each storage extension makes `subject` unique.

## `isGranted` is not `marketingConsentHelper.consentStatus`'s display `granted`

`status(subject)` returns what `marketingConsentHelper.consentStatus` computes for a settings screen: an unanswered
`opt-out` item (e.g. `data.partners`) reads `granted: true` there — the "on until you turn it off"
copy an unauthenticated-feeling first visit shows. `isGranted(subject, key)` is a DIFFERENT
question — the SERVER-SIDE gate a send/share actually checks before doing something — and it must
never honor that display default: an item whose `status` is `'new'` or `'revised'` has never been
affirmatively answered by this person, so `isGranted` answers `false` for it regardless of what
`marketingConsentHelper.consentStatus`'s `granted` says, and `false` too for a key `definitions()` does not carry at all
(unknown or disabled). Only a `'current'` (i.e. actually saved, unrevised) item's `granted` is
trusted. Getting this backwards — wiring a send to `status(...).items.find(...).granted` instead of
`isGranted(...)` — sends to people who opened the settings screen and changed nothing.

## `save` / `recordTerms` — the upsert and the log

- `save` rejects the whole request (`UnknownMarketingConsentError`) if ANY submitted key is not in
  `definitions()` — never silently drops one; a dropped decision is a decision the person believes
  they made and did not (`@owlmeans/marketing-consent`'s own skill).
- Each submitted decision becomes ONE `MarketingConsentLogRecord` (`kind: 'consent'`), appended —
  never updated, never deleted — then the state record's `decisions` array has that key's entry
  REPLACED in place (by key) and the rest left untouched; a second save for the same key never
  duplicates it, it supersedes it. `recordTerms` mirrors this for the state record's single `terms`
  field, without touching `decisions`.
- The state record is upserted by `subject`: an existing row is `save`d over; otherwise `create`
  runs WITHOUT an id, and if that fails for any reason the row is re-read by `subject` — found
  means a concurrent writer won and it is `save`d over, not found rethrows the original error. Do
  not copy `@owlmeans/server-auth-session`'s try-`create`-with-id idiom here: it only works on a
  key-value backend where the key IS the id, and on Mongo/Postgres it fails every first save.
  The append-only log rows are created without an id for the same reason.

## The append-only log as GDPR Art. 7(1) evidence

`MarketingConsentLogRecord` rows are never updated or deleted, including by `purge()` — `purge`
clears only the current-state record (so future `status`/`isGranted` reads see a fresh subject);
the log keeps every decision and terms acceptance ever recorded, because that history is what
"demonstrates consent" under GDPR Art. 7(1) and is grounds for the whole design (`@owlmeans/
marketing-consent`'s own skill's legal matrix). A retention/erasure policy for the log itself is a
product decision this package does not make.

## Where a decision came from — `source`

Each saved decision and each log row carries `source`: `'sign-in'` or `'settings'` from the two
screens, `'api'` from a server-side write. The stored schemas also accept `'cookie'` — historical
rows an earlier device-to-account seeding wrote. The ledger is append-only evidence and must stay
readable, so the value stays valid in storage while nothing writes it and the save request schema
refuses it.

## `observe`

`save`/`recordTerms` call every registered listener, in registration order, each awaited and each
wrapped in its own try/catch — one listener's failure never blocks the write or stops the next
listener (the same shape `@owlmeans/server-auth-identity`'s `IdentityEventsService.
propagateEntityCreated` uses). Use it to fan a decision out to a downstream system (a mailing list, a CRM); never to do work the write itself depends on.

## Testing

Category B: `bun test ./tests` with real `@owlmeans/static-resource` stores for both resources, no
Mongo/Postgres (`tests/context.ts` builds the context and calls `appendMarketingConsentService`
directly — the app never calls `context.init()`/`configure()` in this style of test, since a
`Resource` lookup is synchronous and a `LazyService`'s `lazyInit` fires the first time
`context.service(alias)` is read). `tests/service.spec.ts` covers the upsert-in-place save, the
`isGranted` vs display-`granted` distinction (the single most important test in the package),
`purge` leaving the log untouched, and a throwing observer never blocking a write.
`tests/handlers.spec.ts` binds the three handlers and asserts `save`/`terms` refuse an
`AuthroizationType.AuthToken` request (`refuseTokenAuth`) while `status` never does.
`tests/subject.spec.ts` covers the no-`entity`-resolver case explicitly.

## Related

- `@owlmeans/marketing-consent` — the catalogue, `marketingConsentHelper.consentStatus`, the protocol tree (read its skill
  first; this package builds on it and repeats nothing it already documents)
- `@owlmeans/server-oauth`, `@owlmeans/server-auth-token` — `refuseTokenAuth`, the session-guarded
  handler shape this package's `save`/`terms` copy
- `@owlmeans/server-auth-identity` — `IdentityEventsService`, the `observe`/listener idiom this
  package's `observe` mirrors
- `@owlmeans/marketing-consent-mongo`, `@owlmeans/marketing-consent-postgres` — later workstreams,
  not built yet, that register the two resources this service reads by alias

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…