Skip to content

payload-plugins

Type Skill
Plugin awl-web · v0.0.6
Invoke /awl-web:payload-plugins
Source plugins/awl-web/skills/payload-plugins/SKILL.md

Wire AWL’s @awl/payload-* packages into a Payload CMS v3 project — pick the right package for a need (access policies, typed REST client, change requests / field review workflow, dynamic refs, email templates with placeholders, feature flags, OIDC login) and register it in payload.config.ts with the exact option shapes from the docs. Use whenever someone mentions “payload plugin”, “awl payload”, “payload access policies”, “payload oidc”, “change requests payload”, “dynamic refs”, “payload email templates”, “feature flags payload”, “@awl/payload-api”, or asks how to add SSO, role rules, or a review workflow to a Payload backend. NOT for auditing existing access rules (use payload-security-scan) and NOT for SvelteKit apps without Payload (use sveltekit-app-setup).

Trigger phrases: payload plugin · awl payload · payload access policies · payload oidc · change requests payload · dynamic refs · payload email templates · feature flags payload · @awl/payload-api

Registry docs: https://npm-packages.staging.appswithlove.net/?path=/docs/<id> where <id> is the doc id in the tables below. Every package is TypeScript-only and published to the AWL GitLab npm registry, so two steps repeat for each one: the @awl registry is configured and the package is listed in transpilePackages in next.config.js.

Need Package Doc id Reference
Composable access functions, role matrix @awl/payload-access-policies payload-payload-access-policies--readme references/access-policies.md
Typed REST client for a frontend (SvelteKit, Svelte forms, media) @awl/payload-api payload-payload-api--readme references/api.md
Propose field changes for review instead of writing live @awl/payload-change-requests payload-payload-change-requests--readme references/change-requests.md
Code refers to a document or value an admin picks later @awl/payload-dynamic-refs payload-payload-dynamic-refs--readme references/dynamic-refs.md
Admin-authored emails with {user.name} placeholders @awl/payload-email-templates + @awl/lexical-placeholder payload-payload-email-templates--readme references/email-templates.md, references/lexical.md
Per-request feature flags, optionally managed in the admin @awl/payload-features payload-payload-features--readme references/features.md
Login through Google, Azure, Keycloak; bearer tokens on the API @awl/payload-oidc or @awl/oidc/payload payload-payload-oidc--readme, general-oidc--readme references/oidc.md

Two OIDC packages exist in the registry with different APIs (mapUserAttributes + authStrategy versus resolveUser + optional ownSession). The docs do not say which one supersedes the other, so match what the project already depends on, and for a fresh project check the live docs before choosing.

  1. Read the project. Find payload.config.ts, the users collection, next.config.js, and package.json. Note the Payload major (payload in dependencies): access-policies, change-requests, and features state Payload v3; the others do not state a constraint.
  2. Registry access. Confirm .npmrc or npm config maps @awl to https://gitlab.appswithlove.net/api/v4/projects/3919/packages/npm/ with a token (personal access token locally, CI_JOB_TOKEN in CI). The registry README (readme--docs) has the CI and Docker snippets.
  3. Install and transpile. Add the package with pnpm add, then extend transpilePackages with the entries the package’s doc lists (some pull in @awl/utils, @awl/request, @awl/oidc-client).
  4. Register per package using the shapes below and the matching reference file. Keep collection configs hand-written; the packages only hand you fields, hooks, and access helpers.
  5. Wire the interplay (next section) before writing access rules that depend on req.user.
  6. Verify (last section).

Each snippet is the minimal shape from the docs. Full option tables live in references/.

// access-policies: no config registration, import helpers in collections
import { or, authenticatedOnly, ownerOnly, createAccessMatrix } from '@awl/payload-access-policies'
const matrix = createAccessMatrix({ isAdmin: (args) => args.req.user?.role === 'admin' })
access: { read: or(authenticatedOnly, () => ({ published: true })), delete: matrix({ isAdmin: true }) }
// features.ts: one factory, then features.resolve(req) / features.access('flag') anywhere
export const features = createFeaturesPlugin({ features: { betaNavigation: false }, context: (req) => ({ userRoles: req.user?.roles ?? [] }), rules: [], collection: 'feature-rules' })
// payload.config.ts, only when `collection` is set:
collections: [{ slug: 'feature-rules', fields: featureRuleFields() }]
// dynamic-refs: your own collection built from fields()
import { fields as dynamicRefFields } from '@awl/payload-dynamic-refs'
collections: [{ slug: 'dynamic-refs', admin: { useAsTitle: 'name' }, fields: dynamicRefFields({ relationTo: ['tags'], names: ['AssignStudentTag.tag'] }) }]
// change-requests: one factory, one hand-written collection, hooks on each gated collection
export const changeRequests = createChangeRequests({ /* slug: 'change-requests', usersCollection: 'users' */ })
// ChangeRequests.ts: { slug: changeRequests.slug, access: {...}, fields: changeRequests.fields }
// Courses.ts: hooks: { beforeChange: [changeRequests.hooksFor(['fees']).beforeChange] }
// email-templates: placeholder feature on the richText field, render() at send time
editor: lexicalEditor({ features: ({ defaultFeatures }) => [...defaultFeatures, placeholderFeature()] })
const html = render(template.body, { user: { name: 'Alice' } })
// @awl/payload-oidc
export const oidc = setupOidc({ default: 'google', providers: [{ label: 'google', issuerUrl, clientId, clientSecret, scopes: ['openid', 'email', 'profile'], mapUserAttributes: async (r) => ({ email: r.access.payload('email') }) }] })
buildConfig({ auth: { strategies: [oidc.authStrategy] }, plugins: [oidc.plugin], collections: [{ slug: 'users', auth: true, fields: [...] }] })
// @awl/oidc/payload (resolveUser variant)
const oidc = setupOidc({ providers: [{ label: 'keycloak', issuerUrl, clientId, clientSecret, resolveUser: async (r) => ({ email: r.claims.access?.email as string }) }] })
buildConfig({ plugins: [oidc.plugin] })
// payload-api: frontend client, plugins composed in dependency order
export const payload = createPayloadAPI('/api/payload').plugin(collections).plugin(globals).plugin(uploads)
Combination Rule Why
OIDC + access-policies Write role rules only against fields that mapUserAttributes / resolveUser fills (role, roles). The strategy populates req.user from the IdP claims; a role that never gets mapped is always undefined.
@awl/oidc/payload + ownSession session.plugin is listed before oidc.plugin; users collection sets disableLocalStrategy: true and strategies: [oidc.bearerStrategy, oidc.sessionStrategy]; /admin/login redirects to /api/oidc/login. The session has to exist before the OIDC callback runs, and the local login form has no password to check.
features + access-policies Spread features.featureConditions([...]) into createAccessMatrix with the role conditions. One matrix answers both “is the flag on” and “which role”, first match wins.
change-requests + access-policies Field update access is or(fieldMatrix({ isAdmin: true }), isChangeRequest). Live writes stay reserved to privileged roles; everyone else can only propose through ?changeRequest=true.
change-requests hooks hooksFor() returns single functions, you place them in beforeChange / afterRead next to your own hooks. Nothing is wired automatically, and the order relative to your hooks is yours to decide.
email-templates + lexical-placeholder placeholderFeature() from @awl/lexical-placeholder/payload, then payload generate:importmap. The client feature resolves through the import map; a stale map fails silently at runtime.
payload-api plugins uploads after collections; form after collections and globals; preferences and me need bearerAuth or login. Plugins build on each other’s methods.
payload-api + SvelteKit OIDC payloadProxy(backendUrl) in a catch-all +server.ts forwards the access token from locals as bearer. The frontend client never holds the Payload URL or token.
dynamic-refs + multi-tenant Add a tenant field to your collection and pass where: { tenant: { equals: tenantId } } to resolveDynamicRef. name is not unique, so an unscoped lookup returns an arbitrary tenant’s row.

The packages read nothing from process.env themselves; the docs pass values in explicitly. Names used in the examples, so projects stay consistent:

Variable Used by
PAYLOAD_PUBLIC_SERVER_URL serverURL in buildConfig, login link in the custom login page
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET @awl/payload-oidc provider clientId, clientSecret, audience
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_KEYCLOAK_ISSUER_URL, OIDC_KEYCLOAK_CLIENT_ID, OIDC_KEYCLOAK_CLIENT_SECRET @awl/oidc/payload providers
OIDC_SECRET CookieDataStorage secret for the SvelteKit session (frontend side)
NODE_ENV Example context in @awl/payload-features

Secrets stay out of git; the example env file lists the names with empty values.

  1. pnpm payload generate:types succeeds and, when placeholderFeature() was added, pnpm payload generate:importmap ran.
  2. The dev server boots without “module not found” for an @awl/* package; if it fails, the transpilePackages entry is missing.
  3. OIDC: open /api/oidc/login?provider=<label>&redirect=/admin, complete the IdP flow, and confirm a user row with a filled sub (or the resolveUser fields) exists. A curl -H "Authorization: Bearer <token>" /api/<collection> returns the same data as the admin session.
  4. Access policies: as an anonymous client curl /api/<collection> and as each role; compare with the intended matrix. For a full audit of the resulting rules run payload-security-scan.
  5. Change requests: a PATCH /api/<collection>/<id>?changeRequest=true with a gated field creates a row in the change-requests collection and leaves the live document untouched; changeRequests.apply(req, ...) writes it and deletes the row.
  6. Features: a custom /flags endpoint returning features.resolve(req) flips per role or per DB rule.
  7. Dynamic refs: resolveDynamicRef returns undefined until an admin picks a document, then the populated document.
  8. Email templates: render(body, context) replaces {user.name} and keeps unknown keys as literal {key}.
  • payload-security-scan: audits the access rules after wiring.
  • sveltekit-app-setup: SvelteKit side of @awl/oidc and @awl/session without Payload.